@antfu/design 0.1.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/LICENSE +21 -0
- package/README.md +165 -0
- package/a11y/cli.ts +73 -0
- package/a11y/index.ts +13 -0
- package/a11y/scan.ts +127 -0
- package/components/Action/ActionButton.stories.ts +56 -0
- package/components/Action/ActionButton.vue +57 -0
- package/components/Action/ActionDarkToggle.stories.ts +31 -0
- package/components/Action/ActionDarkToggle.vue +87 -0
- package/components/Action/ActionIconButton.stories.ts +47 -0
- package/components/Action/ActionIconButton.vue +47 -0
- package/components/Display/DisplayAvatar.stories.ts +36 -0
- package/components/Display/DisplayAvatar.vue +58 -0
- package/components/Display/DisplayBadge.stories.ts +31 -0
- package/components/Display/DisplayBadge.vue +98 -0
- package/components/Display/DisplayBytes.stories.ts +28 -0
- package/components/Display/DisplayBytes.vue +30 -0
- package/components/Display/DisplayDate.stories.ts +37 -0
- package/components/Display/DisplayDate.vue +29 -0
- package/components/Display/DisplayDonut.stories.ts +26 -0
- package/components/Display/DisplayDonut.vue +46 -0
- package/components/Display/DisplayDuration.stories.ts +28 -0
- package/components/Display/DisplayDuration.vue +28 -0
- package/components/Display/DisplayFileIcon.stories.ts +27 -0
- package/components/Display/DisplayFileIcon.vue +30 -0
- package/components/Display/DisplayFilePath.stories.ts +30 -0
- package/components/Display/DisplayFilePath.vue +61 -0
- package/components/Display/DisplayKbd.stories.ts +26 -0
- package/components/Display/DisplayKbd.vue +27 -0
- package/components/Display/DisplayKeyValue.stories.ts +56 -0
- package/components/Display/DisplayKeyValue.vue +51 -0
- package/components/Display/DisplayLabel.stories.ts +27 -0
- package/components/Display/DisplayLabel.vue +33 -0
- package/components/Display/DisplayNumber.stories.ts +27 -0
- package/components/Display/DisplayNumber.vue +24 -0
- package/components/Display/DisplayNumberBadge.stories.ts +26 -0
- package/components/Display/DisplayNumberBadge.vue +22 -0
- package/components/Display/DisplayPackageName.stories.ts +26 -0
- package/components/Display/DisplayPackageName.vue +49 -0
- package/components/Display/DisplayProgressBar.stories.ts +29 -0
- package/components/Display/DisplayProgressBar.vue +90 -0
- package/components/Display/DisplayProportionBar.stories.ts +40 -0
- package/components/Display/DisplayProportionBar.vue +43 -0
- package/components/Display/DisplaySafeImage.stories.ts +43 -0
- package/components/Display/DisplaySafeImage.vue +30 -0
- package/components/Display/DisplayStatusPill.stories.ts +34 -0
- package/components/Display/DisplayStatusPill.vue +42 -0
- package/components/Display/DisplayTree.stories.ts +76 -0
- package/components/Display/DisplayTree.vue +102 -0
- package/components/Display/DisplayVersion.stories.ts +25 -0
- package/components/Display/DisplayVersion.vue +21 -0
- package/components/Feedback/FeedbackEmptyState.stories.ts +38 -0
- package/components/Feedback/FeedbackEmptyState.vue +21 -0
- package/components/Feedback/FeedbackLoading.stories.ts +23 -0
- package/components/Feedback/FeedbackLoading.vue +21 -0
- package/components/Feedback/FeedbackSpinner.stories.ts +25 -0
- package/components/Feedback/FeedbackSpinner.vue +22 -0
- package/components/Feedback/FeedbackTip.stories.ts +34 -0
- package/components/Feedback/FeedbackTip.vue +29 -0
- package/components/Feedback/FeedbackToasts.stories.ts +40 -0
- package/components/Feedback/FeedbackToasts.vue +105 -0
- package/components/Form/FormCheckbox.stories.ts +36 -0
- package/components/Form/FormCheckbox.vue +30 -0
- package/components/Form/FormCombobox.stories.ts +35 -0
- package/components/Form/FormCombobox.vue +83 -0
- package/components/Form/FormField.stories.ts +56 -0
- package/components/Form/FormField.vue +36 -0
- package/components/Form/FormNumberInput.stories.ts +47 -0
- package/components/Form/FormNumberInput.vue +85 -0
- package/components/Form/FormRadioGroup.stories.ts +47 -0
- package/components/Form/FormRadioGroup.vue +43 -0
- package/components/Form/FormSearchField.stories.ts +22 -0
- package/components/Form/FormSearchField.vue +32 -0
- package/components/Form/FormSelect.stories.ts +47 -0
- package/components/Form/FormSelect.vue +56 -0
- package/components/Form/FormSwitch.stories.ts +36 -0
- package/components/Form/FormSwitch.vue +26 -0
- package/components/Form/FormTextInput.stories.ts +39 -0
- package/components/Form/FormTextInput.vue +51 -0
- package/components/Form/FormTextarea.stories.ts +47 -0
- package/components/Form/FormTextarea.vue +32 -0
- package/components/Layout/LayoutBreadcrumb.stories.ts +54 -0
- package/components/Layout/LayoutBreadcrumb.vue +54 -0
- package/components/Layout/LayoutCard.stories.ts +31 -0
- package/components/Layout/LayoutCard.vue +21 -0
- package/components/Layout/LayoutDataTable.stories.ts +77 -0
- package/components/Layout/LayoutDataTable.vue +145 -0
- package/components/Layout/LayoutExpandableList.stories.ts +28 -0
- package/components/Layout/LayoutExpandableList.vue +94 -0
- package/components/Layout/LayoutPanelGrids.stories.ts +28 -0
- package/components/Layout/LayoutPanelGrids.vue +26 -0
- package/components/Layout/LayoutSectionBlock.stories.ts +37 -0
- package/components/Layout/LayoutSectionBlock.vue +37 -0
- package/components/Layout/LayoutSideNav.stories.ts +33 -0
- package/components/Layout/LayoutSideNav.vue +48 -0
- package/components/Layout/LayoutSplitPane.stories.ts +44 -0
- package/components/Layout/LayoutSplitPane.vue +30 -0
- package/components/Layout/LayoutTabs.stories.ts +43 -0
- package/components/Layout/LayoutTabs.vue +56 -0
- package/components/Layout/LayoutToolbar.stories.ts +60 -0
- package/components/Layout/LayoutToolbar.vue +28 -0
- package/components/Layout/LayoutVirtualList.stories.ts +30 -0
- package/components/Layout/LayoutVirtualList.vue +82 -0
- package/components/Overlay/OverlayDrawer.stories.ts +47 -0
- package/components/Overlay/OverlayDrawer.vue +58 -0
- package/components/Overlay/OverlayDropdown.stories.ts +25 -0
- package/components/Overlay/OverlayDropdown.vue +30 -0
- package/components/Overlay/OverlayDropdownItem.stories.ts +26 -0
- package/components/Overlay/OverlayDropdownItem.vue +31 -0
- package/components/Overlay/OverlayDropdownLabel.vue +9 -0
- package/components/Overlay/OverlayDropdownSeparator.vue +7 -0
- package/components/Overlay/OverlayModal.stories.ts +33 -0
- package/components/Overlay/OverlayModal.vue +48 -0
- package/components/Overlay/OverlayTooltip.stories.ts +33 -0
- package/components/Overlay/OverlayTooltip.vue +38 -0
- package/composables/colorScheme.ts +58 -0
- package/composables/toast.ts +81 -0
- package/package.json +99 -0
- package/skills/antfu-design/SKILL.md +65 -0
- package/skills/antfu-design/references/advanced-patterns.md +39 -0
- package/skills/antfu-design/references/best-practices.md +54 -0
- package/skills/antfu-design/references/core-components.md +72 -0
- package/skills/antfu-design/references/core-setup.md +56 -0
- package/skills/antfu-design/references/core-tokens.md +100 -0
- package/skills/antfu-design/references/features-data-presentation.md +27 -0
- package/splitpanes.d.ts +70 -0
- package/styles/animations.css +47 -0
- package/styles/base.css +31 -0
- package/styles/floating-vue.css +28 -0
- package/styles/index.css +7 -0
- package/styles/reka-ui.css +112 -0
- package/styles/scrollbar.css +24 -0
- package/styles/splitpanes.css +61 -0
- package/unocss/colors.ts +127 -0
- package/unocss/index.ts +99 -0
- package/unocss/options.ts +31 -0
- package/unocss/patterns.ts +38 -0
- package/unocss/rules.ts +26 -0
- package/unocss/severity.ts +16 -0
- package/unocss/shortcuts.ts +68 -0
- package/utils/color.ts +328 -0
- package/utils/contrast.ts +118 -0
- package/utils/format.ts +389 -0
- package/utils/icon.ts +200 -0
- package/utils/index.ts +13 -0
- package/utils/keybinding.ts +199 -0
- package/utils/misc.ts +141 -0
- package/utils/path.ts +243 -0
- package/utils/semver.ts +147 -0
- package/utils/tree.ts +89 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard chord parsing + platform-aware display, adapted from ghfs's
|
|
3
|
+
* `parseKey`. Pure (the one environment touch, `isMac`, degrades gracefully on
|
|
4
|
+
* the server). Kept for the `Kbd` component; the command-registry pairing is
|
|
5
|
+
* deferred with the command palette.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether the current environment is an Apple platform (macOS/iOS).
|
|
10
|
+
*
|
|
11
|
+
* Detected from `navigator.platform`/`userAgent`; `false` on the server or any
|
|
12
|
+
* non-Apple platform. Drives the `mod` alias (→ `meta` on Apple, `ctrl`
|
|
13
|
+
* elsewhere) and glyph vs. label display.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* isMac // → true on macOS, false on Windows/Linux/server
|
|
17
|
+
*/
|
|
18
|
+
export const isMac: boolean = typeof navigator !== 'undefined'
|
|
19
|
+
&& /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent)
|
|
20
|
+
|
|
21
|
+
export interface ParsedChord {
|
|
22
|
+
/** Modifiers, alphabetically ordered: `alt` | `ctrl` | `meta` | `shift`. */
|
|
23
|
+
modifiers: string[]
|
|
24
|
+
/** The final non-modifier key, e.g. `k`, `Enter`, `ArrowUp`, `/`. */
|
|
25
|
+
key: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MOD_ALIASES: Record<string, string> = {
|
|
29
|
+
mod: isMac ? 'meta' : 'ctrl',
|
|
30
|
+
cmd: 'meta',
|
|
31
|
+
command: 'meta',
|
|
32
|
+
super: 'meta',
|
|
33
|
+
win: 'meta',
|
|
34
|
+
meta: 'meta',
|
|
35
|
+
ctrl: 'ctrl',
|
|
36
|
+
control: 'ctrl',
|
|
37
|
+
alt: 'alt',
|
|
38
|
+
option: 'alt',
|
|
39
|
+
opt: 'alt',
|
|
40
|
+
shift: 'shift',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KEY_ALIASES: Record<string, string> = {
|
|
44
|
+
esc: 'Escape',
|
|
45
|
+
escape: 'Escape',
|
|
46
|
+
enter: 'Enter',
|
|
47
|
+
return: 'Enter',
|
|
48
|
+
space: ' ',
|
|
49
|
+
tab: 'Tab',
|
|
50
|
+
up: 'ArrowUp',
|
|
51
|
+
down: 'ArrowDown',
|
|
52
|
+
left: 'ArrowLeft',
|
|
53
|
+
right: 'ArrowRight',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MOD_ORDER = ['ctrl', 'alt', 'shift', 'meta']
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a single chord like `mod+shift+k`.
|
|
60
|
+
*
|
|
61
|
+
* Splits on `+`, resolving modifier aliases (`mod`/`cmd`/`ctrl`/`alt`/`shift`,
|
|
62
|
+
* etc.) and key aliases (`esc`→`Escape`, `up`→`ArrowUp`, …). Modifiers are
|
|
63
|
+
* returned in canonical `ctrl, alt, shift, meta` order; the last non-modifier
|
|
64
|
+
* token becomes `key`. The platform-sensitive `mod` resolves to `meta` on Apple
|
|
65
|
+
* and `ctrl` elsewhere (see {@link isMac}).
|
|
66
|
+
*
|
|
67
|
+
* @param input - The chord string, e.g. `'mod+shift+k'`.
|
|
68
|
+
* @returns A {@link ParsedChord} with ordered `modifiers` and the final `key`.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* const chord = parseChord('mod+shift+k')
|
|
72
|
+
* chord.key // → 'k'
|
|
73
|
+
* chord.modifiers // → ['shift', 'meta'] on macOS, ['ctrl', 'shift'] elsewhere
|
|
74
|
+
*/
|
|
75
|
+
export function parseChord(input: string): ParsedChord {
|
|
76
|
+
const tokens = input.trim().split('+').map(t => t.trim()).filter(Boolean)
|
|
77
|
+
const modifiers = new Set<string>()
|
|
78
|
+
let key = ''
|
|
79
|
+
for (const token of tokens) {
|
|
80
|
+
const lower = token.toLowerCase()
|
|
81
|
+
if (lower in MOD_ALIASES)
|
|
82
|
+
modifiers.add(MOD_ALIASES[lower])
|
|
83
|
+
else
|
|
84
|
+
key = KEY_ALIASES[lower] ?? (token.length === 1 ? token.toLowerCase() : token)
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
modifiers: MOD_ORDER.filter(m => modifiers.has(m)),
|
|
88
|
+
key,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a whitespace-separated chord sequence (`g g`, `ctrl+k p`).
|
|
94
|
+
*
|
|
95
|
+
* Each whitespace-delimited token is parsed with {@link parseChord}, producing an
|
|
96
|
+
* ordered list of chords for multi-keystroke bindings.
|
|
97
|
+
*
|
|
98
|
+
* @param binding - The binding string, e.g. `'g g'` or `'ctrl+k p'`.
|
|
99
|
+
* @returns An array of {@link ParsedChord}, one per chord in the sequence.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* parseBinding('g g') // → [{ modifiers: [], key: 'g' }, { modifiers: [], key: 'g' }] (length 2)
|
|
103
|
+
*/
|
|
104
|
+
export function parseBinding(binding: string): ParsedChord[] {
|
|
105
|
+
return binding.trim().split(/\s+/).filter(Boolean).map(parseChord)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Canonical token for matching, e.g. `ctrl+shift+k`.
|
|
110
|
+
*
|
|
111
|
+
* Joins the (already ordered) modifiers and the lower-cased key with `+`,
|
|
112
|
+
* producing a stable string suitable for keying a binding map or comparing
|
|
113
|
+
* against {@link eventToToken}.
|
|
114
|
+
*
|
|
115
|
+
* @param chord - The parsed chord to serialize.
|
|
116
|
+
* @returns The canonical `+`-joined token.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* chordToken(parseChord('shift+ctrl+a')) // → 'ctrl+shift+a'
|
|
120
|
+
*/
|
|
121
|
+
export function chordToken(chord: ParsedChord): string {
|
|
122
|
+
return [...chord.modifiers, chord.key.toLowerCase()].join('+')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Token from a real keyboard event.
|
|
127
|
+
*
|
|
128
|
+
* Reads the event's modifier flags and `key`, applies the same key aliases and
|
|
129
|
+
* canonical modifier ordering as {@link parseChord}, and returns a token that
|
|
130
|
+
* can be compared directly against {@link chordToken}.
|
|
131
|
+
*
|
|
132
|
+
* @param event - The `KeyboardEvent` to serialize.
|
|
133
|
+
* @returns The canonical `+`-joined token for the event.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* // For a Ctrl+K keydown event:
|
|
137
|
+
* eventToToken(event) // → 'ctrl+k'
|
|
138
|
+
*/
|
|
139
|
+
export function eventToToken(event: KeyboardEvent): string {
|
|
140
|
+
const modifiers: string[] = []
|
|
141
|
+
if (event.ctrlKey)
|
|
142
|
+
modifiers.push('ctrl')
|
|
143
|
+
if (event.altKey)
|
|
144
|
+
modifiers.push('alt')
|
|
145
|
+
if (event.shiftKey)
|
|
146
|
+
modifiers.push('shift')
|
|
147
|
+
if (event.metaKey)
|
|
148
|
+
modifiers.push('meta')
|
|
149
|
+
const key = KEY_ALIASES[event.key.toLowerCase()] ?? event.key
|
|
150
|
+
return [...MOD_ORDER.filter(m => modifiers.includes(m)), key.toLowerCase()].join('+')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const GLYPHS_MAC: Record<string, string> = { meta: '⌘', ctrl: '⌃', alt: '⌥', shift: '⇧' }
|
|
154
|
+
const LABELS: Record<string, string> = { meta: 'Win', ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift' }
|
|
155
|
+
const KEY_GLYPHS: Record<string, string> = {
|
|
156
|
+
'Enter': '↵',
|
|
157
|
+
'Escape': 'Esc',
|
|
158
|
+
'ArrowUp': '↑',
|
|
159
|
+
'ArrowDown': '↓',
|
|
160
|
+
'ArrowLeft': '←',
|
|
161
|
+
'ArrowRight': '→',
|
|
162
|
+
' ': 'Space',
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Render a chord into display tokens, e.g. `['⌘', '⇧', 'K']` on macOS.
|
|
167
|
+
*
|
|
168
|
+
* Modifiers become platform glyphs on Apple (`⌘ ⌃ ⌥ ⇧`) or word labels
|
|
169
|
+
* elsewhere (`Win`, `Ctrl`, `Alt`, `Shift`); the key becomes its glyph (e.g.
|
|
170
|
+
* `↵`, `↑`) or an upper-cased single character.
|
|
171
|
+
*
|
|
172
|
+
* @param chord - The parsed chord to render.
|
|
173
|
+
* @returns An array of display tokens, modifiers first then the key.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* chordDisplay(parseChord('ctrl+k'))
|
|
177
|
+
* // → ['⌃', 'K'] on macOS, ['Ctrl', 'K'] elsewhere
|
|
178
|
+
*/
|
|
179
|
+
export function chordDisplay(chord: ParsedChord): string[] {
|
|
180
|
+
const mods = chord.modifiers.map(m => isMac ? GLYPHS_MAC[m] : LABELS[m])
|
|
181
|
+
const key = KEY_GLYPHS[chord.key] ?? (chord.key.length === 1 ? chord.key.toUpperCase() : chord.key)
|
|
182
|
+
return [...mods, key]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Flatten a full binding sequence into display tokens.
|
|
187
|
+
*
|
|
188
|
+
* Parses the binding with {@link parseBinding} and concatenates each chord's
|
|
189
|
+
* {@link chordDisplay} tokens into a single flat array.
|
|
190
|
+
*
|
|
191
|
+
* @param binding - The binding string, e.g. `'ctrl+k'` or `'g g'`.
|
|
192
|
+
* @returns A flat array of display tokens for the whole sequence.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* bindingDisplay('ctrl+k') // → ['⌃', 'K'] on macOS, ['Ctrl', 'K'] elsewhere (non-empty)
|
|
196
|
+
*/
|
|
197
|
+
export function bindingDisplay(binding: string): string[] {
|
|
198
|
+
return parseBinding(binding).flatMap(chordDisplay)
|
|
199
|
+
}
|
package/utils/misc.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small design-system-specific helpers. Generic helpers (`clamp`, `toArray`, …)
|
|
3
|
+
* come from `@antfu/utils` — re-exported from `./index` rather than reimplemented
|
|
4
|
+
* here — so we ship only what's specific to this library.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether a value represents a finite number.
|
|
9
|
+
*
|
|
10
|
+
* Finite numbers pass; numeric strings (after trimming, non-empty) pass; `NaN`,
|
|
11
|
+
* `Infinity`, empty/blank strings, and non-string/non-number values fail.
|
|
12
|
+
*
|
|
13
|
+
* @param value - The value to test.
|
|
14
|
+
* @returns `true` if `value` is a finite number or a numeric string.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* isNumeric('42') // → true
|
|
18
|
+
* isNumeric('x') // → false
|
|
19
|
+
*/
|
|
20
|
+
export function isNumeric(value: unknown): boolean {
|
|
21
|
+
if (typeof value === 'number')
|
|
22
|
+
return Number.isFinite(value)
|
|
23
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
24
|
+
return false
|
|
25
|
+
return !Number.isNaN(Number(value))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format a number with its English ordinal suffix.
|
|
30
|
+
*
|
|
31
|
+
* Handles the 11–13 special cases (which take `th`) correctly.
|
|
32
|
+
*
|
|
33
|
+
* @param n - The number to format.
|
|
34
|
+
* @returns The number followed by its ordinal suffix, e.g. `'1st'`.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* nth(1) // → '1st'
|
|
38
|
+
* nth(22) // → '22nd'
|
|
39
|
+
* nth(23) // → '23rd'
|
|
40
|
+
*/
|
|
41
|
+
export function nth(n: number): string {
|
|
42
|
+
const s = ['th', 'st', 'nd', 'rd']
|
|
43
|
+
const v = n % 100
|
|
44
|
+
return `${n}${s[(v - 20) % 10] ?? s[v] ?? s[0]}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pick the singular or plural form based on a count.
|
|
49
|
+
*
|
|
50
|
+
* Returns `singular` only when `count` is exactly `1`; any other count (including
|
|
51
|
+
* `0`) uses the plural form.
|
|
52
|
+
*
|
|
53
|
+
* @param count - The count that decides the form.
|
|
54
|
+
* @param singular - The singular word.
|
|
55
|
+
* @param plural - The plural word. Defaults to `singular` + `'s'`.
|
|
56
|
+
* @returns The appropriate word for the count.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* pluralize(1, 'item') // → 'item'
|
|
60
|
+
* pluralize(2, 'item') // → 'items'
|
|
61
|
+
*/
|
|
62
|
+
export function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
|
63
|
+
return count === 1 ? singular : plural
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse JSON without throwing, falling back on error.
|
|
68
|
+
*
|
|
69
|
+
* Returns the parsed value on success; on a parse error returns `fallback`
|
|
70
|
+
* (which is `undefined` when omitted).
|
|
71
|
+
*
|
|
72
|
+
* @param input - The JSON string to parse.
|
|
73
|
+
* @param fallback - Value to return if parsing fails. Defaults to `undefined`.
|
|
74
|
+
* @returns The parsed value, or `fallback` when parsing throws.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* safeJsonParse('{"a":1}') // → { a: 1 }
|
|
78
|
+
* safeJsonParse('oops', null) // → null
|
|
79
|
+
*/
|
|
80
|
+
export function safeJsonParse<T = unknown>(input: string, fallback?: T): T | undefined {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(input) as T
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return fallback
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stringify a value as JSON but with unquoted keys where valid — for compact
|
|
91
|
+
* config display (`{ foo: 1, "a-b": 2 }`).
|
|
92
|
+
*
|
|
93
|
+
* Runs `JSON.stringify` then removes quotes from object keys that are valid
|
|
94
|
+
* bare JS identifiers; keys needing quotes (e.g. those with hyphens) are left
|
|
95
|
+
* quoted.
|
|
96
|
+
*
|
|
97
|
+
* @param value - The value to stringify.
|
|
98
|
+
* @param indent - Indentation passed to `JSON.stringify`. Defaults to `2`.
|
|
99
|
+
* @returns A JSON-like string with identifier keys unquoted.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* stringifyUnquoted({ foo: 1, 'a-b': 2 })
|
|
103
|
+
* // → '{\n foo: 1,\n "a-b": 2\n}' (contains `foo:`, keeps `"a-b"` quoted)
|
|
104
|
+
*/
|
|
105
|
+
export function stringifyUnquoted(value: unknown, indent = 2): string {
|
|
106
|
+
const json = JSON.stringify(value, null, indent)
|
|
107
|
+
return json.replace(/^(\s*)"([A-Z_$][\w$]*)":/gim, '$1$2:')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Memoize a single-argument function. The optional `getKey` derives a cache key
|
|
112
|
+
* (default identity), so reference-stable args dedupe correctly.
|
|
113
|
+
*
|
|
114
|
+
* Results are cached in a `Map` keyed by `getKey(arg)`; a cached result is
|
|
115
|
+
* returned on subsequent calls without re-invoking `fn`. The cache lives for the
|
|
116
|
+
* lifetime of the returned function and is never evicted.
|
|
117
|
+
*
|
|
118
|
+
* @param fn - The single-argument function to memoize.
|
|
119
|
+
* @param getKey - Derives the cache key from the argument. Defaults to identity (`a => a`).
|
|
120
|
+
* @returns A memoized wrapper with the same signature as `fn`.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* let calls = 0
|
|
124
|
+
* const double = makeCachedFunction((x: number) => { calls++; return x * 2 })
|
|
125
|
+
* double(2) // → 4 (calls === 1)
|
|
126
|
+
* double(2) // → 4 (cached; calls still 1)
|
|
127
|
+
*/
|
|
128
|
+
export function makeCachedFunction<A, R>(
|
|
129
|
+
fn: (arg: A) => R,
|
|
130
|
+
getKey: (arg: A) => unknown = a => a,
|
|
131
|
+
): (arg: A) => R {
|
|
132
|
+
const cache = new Map<unknown, R>()
|
|
133
|
+
return (arg: A): R => {
|
|
134
|
+
const key = getKey(arg)
|
|
135
|
+
if (cache.has(key))
|
|
136
|
+
return cache.get(key)!
|
|
137
|
+
const result = fn(arg)
|
|
138
|
+
cache.set(key, result)
|
|
139
|
+
return result
|
|
140
|
+
}
|
|
141
|
+
}
|
package/utils/path.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-id / file-path parsing. Generalized from the byte-for-byte duplicate
|
|
3
|
+
* that lives in both nuxt-devtools and vite-devtools, including the `.pnpm`
|
|
4
|
+
* decoding used to render readable package paths.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize Windows backslashes to forward slashes.
|
|
9
|
+
*
|
|
10
|
+
* @param path - The file path or module id to normalize.
|
|
11
|
+
* @returns The path with every `\` replaced by `/`.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* normalizeModulePath('C:\\x\\y') // → 'C:/x/y'
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeModulePath(path: string): string {
|
|
17
|
+
return path.replace(/\\/g, '/')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a path passes through a `node_modules` directory.
|
|
22
|
+
*
|
|
23
|
+
* Normalizes separators first, so Windows paths are handled.
|
|
24
|
+
*
|
|
25
|
+
* @param path - The file path or module id to test.
|
|
26
|
+
* @returns `true` if a `node_modules` segment is present.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* isNodeModulePath('/x/node_modules/foo/index.js') // → true
|
|
30
|
+
* isNodeModulePath('/x/src/a.ts') // → false
|
|
31
|
+
*/
|
|
32
|
+
export function isNodeModulePath(path: string): boolean {
|
|
33
|
+
return /\bnode_modules\b/.test(normalizeModulePath(path))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether an id looks like a bare package specifier (not a relative/absolute path).
|
|
38
|
+
*
|
|
39
|
+
* Rejects empty strings, ids starting with `.` or `/`, protocol-prefixed ids
|
|
40
|
+
* (`foo:`), and Windows drive paths; otherwise requires a valid (optionally
|
|
41
|
+
* scoped) npm package name shape.
|
|
42
|
+
*
|
|
43
|
+
* @param id - The module id to test.
|
|
44
|
+
* @returns `true` if `id` is a bare package specifier.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* isPackageName('foo') // → true
|
|
48
|
+
* isPackageName('@scope/name') // → true
|
|
49
|
+
* isPackageName('./foo') // → false
|
|
50
|
+
* isPackageName('/abs/path') // → false
|
|
51
|
+
*/
|
|
52
|
+
export function isPackageName(id: string): boolean {
|
|
53
|
+
if (!id || id.startsWith('.') || id.startsWith('/') || /^[a-z]+:/i.test(id) || /^[a-z]:[\\/]/i.test(id))
|
|
54
|
+
return false
|
|
55
|
+
return /^(?:@[\w.-]+\/)?[\w.-]+(?:\/[\w.-]+)*$/.test(id)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PnpmPackageInfo {
|
|
59
|
+
name: string
|
|
60
|
+
version: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decode a `.pnpm` directory segment like `foo@1.2.3`, `@scope+name@1.2.3` or
|
|
65
|
+
* `foo@1.2.3_peer@4` into `{ name, version }`.
|
|
66
|
+
*
|
|
67
|
+
* Strips any `_peer@...` suffix, splits name from version at the last `@`, and
|
|
68
|
+
* decodes pnpm's `@scope+name` scope encoding back to `@scope/name`. Returns
|
|
69
|
+
* `undefined` when no version `@` is present.
|
|
70
|
+
*
|
|
71
|
+
* @param segment - The raw `.pnpm` segment to decode.
|
|
72
|
+
* @returns A {@link PnpmPackageInfo}, or `undefined` if the segment has no version.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* parsePnpmSegment('@scope+name@1.2.3_react@18') // → { name: '@scope/name', version: '1.2.3' }
|
|
76
|
+
* parsePnpmSegment('foo@1.2.3') // → { name: 'foo', version: '1.2.3' }
|
|
77
|
+
*/
|
|
78
|
+
export function parsePnpmSegment(segment: string): PnpmPackageInfo | undefined {
|
|
79
|
+
// Strip peer-dependency suffix (`_react@18...`).
|
|
80
|
+
const base = segment.split('_')[0]
|
|
81
|
+
const at = base.lastIndexOf('@')
|
|
82
|
+
if (at <= 0)
|
|
83
|
+
return undefined
|
|
84
|
+
const rawName = base.slice(0, at)
|
|
85
|
+
const version = base.slice(at + 1)
|
|
86
|
+
// pnpm encodes scopes as `@scope+name` (sometimes without the leading `@`).
|
|
87
|
+
let name = rawName
|
|
88
|
+
if (rawName.includes('+')) {
|
|
89
|
+
name = rawName.replace('+', '/')
|
|
90
|
+
if (!name.startsWith('@'))
|
|
91
|
+
name = `@${name}`
|
|
92
|
+
}
|
|
93
|
+
return { name, version }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract `{ name, version }` from a path that traverses a `.pnpm` store.
|
|
98
|
+
*
|
|
99
|
+
* Finds the segment immediately after `.pnpm/` and decodes it via
|
|
100
|
+
* {@link parsePnpmSegment}. Returns `undefined` when the path has no `.pnpm`
|
|
101
|
+
* directory.
|
|
102
|
+
*
|
|
103
|
+
* @param path - The file path to inspect.
|
|
104
|
+
* @returns A {@link PnpmPackageInfo}, or `undefined` if not a `.pnpm` path.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* getPnpmPackageInfoFromPath('/x/node_modules/.pnpm/foo@1.2.3/node_modules/foo/i.js')
|
|
108
|
+
* // → { name: 'foo', version: '1.2.3' }
|
|
109
|
+
*/
|
|
110
|
+
export function getPnpmPackageInfoFromPath(path: string): PnpmPackageInfo | undefined {
|
|
111
|
+
const match = normalizeModulePath(path).match(/\.pnpm\/([^/]+)/)
|
|
112
|
+
if (!match)
|
|
113
|
+
return undefined
|
|
114
|
+
return parsePnpmSegment(match[1])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get the package name from a `node_modules` path (`.../node_modules/@scope/name/dist/x` → `@scope/name`).
|
|
119
|
+
*
|
|
120
|
+
* Prefers a `.pnpm`-decoded name when present; otherwise reads the segment(s)
|
|
121
|
+
* after the last `node_modules/`, keeping the `@scope/name` pair for scoped
|
|
122
|
+
* packages. Returns `undefined` when the path is not inside `node_modules`.
|
|
123
|
+
*
|
|
124
|
+
* @param path - The file path to inspect.
|
|
125
|
+
* @returns The package name, or `undefined` if none can be resolved.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* getModuleNameFromPath('/x/node_modules/@scope/name/dist/i.js') // → '@scope/name'
|
|
129
|
+
* getModuleNameFromPath('/x/node_modules/foo/index.js') // → 'foo'
|
|
130
|
+
* getModuleNameFromPath('/x/src/a.ts') // → undefined
|
|
131
|
+
*/
|
|
132
|
+
export function getModuleNameFromPath(path: string): string | undefined {
|
|
133
|
+
const normalized = normalizeModulePath(path)
|
|
134
|
+
const pnpm = getPnpmPackageInfoFromPath(normalized)
|
|
135
|
+
if (pnpm)
|
|
136
|
+
return pnpm.name
|
|
137
|
+
const idx = normalized.lastIndexOf('node_modules/')
|
|
138
|
+
if (idx === -1)
|
|
139
|
+
return undefined
|
|
140
|
+
const rest = normalized.slice(idx + 'node_modules/'.length)
|
|
141
|
+
const segments = rest.split('/')
|
|
142
|
+
return segments[0].startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Collapse a `.pnpm/<pkg>@ver/node_modules/<pkg>` chunk to `~`, leaving the tail.
|
|
147
|
+
*
|
|
148
|
+
* Replaces everything up to and including the inner `node_modules/` of a `.pnpm`
|
|
149
|
+
* store path with the `replacement` prefix, yielding a short, readable path.
|
|
150
|
+
*
|
|
151
|
+
* @param path - The file path to collapse.
|
|
152
|
+
* @param replacement - The prefix to substitute for the store chunk. Defaults to `'~/'`.
|
|
153
|
+
* @returns The path with the `.pnpm` store prefix replaced.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* collapsePnpmPath('/x/node_modules/.pnpm/foo@1.2.3/node_modules/foo/index.js')
|
|
157
|
+
* // → '~/foo/index.js'
|
|
158
|
+
*/
|
|
159
|
+
export function collapsePnpmPath(path: string, replacement = '~/'): string {
|
|
160
|
+
return normalizeModulePath(path).replace(/.*\.pnpm\/[^/]+\/node_modules\//, replacement)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface RelativeModulePathOptions {
|
|
164
|
+
/** Prefix substituted for a `.pnpm` store chunk. Defaults to `'~/'`. */
|
|
165
|
+
pnpmCollapse?: string
|
|
166
|
+
/** Beyond this many `../` levels, return the absolute path instead. Defaults to `3`. */
|
|
167
|
+
maxUp?: number
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Make a module id readable relative to a project root: trims the root prefix,
|
|
172
|
+
* decodes `.pnpm` to `~`, and keeps absolute past 3 levels of `../`.
|
|
173
|
+
*
|
|
174
|
+
* `.pnpm` paths are collapsed via {@link collapsePnpmPath}; if the id lives under
|
|
175
|
+
* `root`, the root prefix is stripped and a `./` prefix added; otherwise a
|
|
176
|
+
* `../`-relative path is synthesized, falling back to the absolute path once it
|
|
177
|
+
* would need more than `maxUp` (default 3) `../` hops.
|
|
178
|
+
*
|
|
179
|
+
* @param id - The module id or file path to make relative.
|
|
180
|
+
* @param root - The project root to make the path relative to.
|
|
181
|
+
* @param options - See {@link RelativeModulePathOptions}.
|
|
182
|
+
* @returns A readable, root-relative path.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* relativeModulePath('/root/src/a.ts', '/root') // → './src/a.ts'
|
|
186
|
+
* relativeModulePath('/root/pkg/b.ts', '/root/app') // → '../pkg/b.ts'
|
|
187
|
+
*/
|
|
188
|
+
export function relativeModulePath(id: string, root: string, options: RelativeModulePathOptions = {}): string {
|
|
189
|
+
const { pnpmCollapse = '~/', maxUp = 3 } = options
|
|
190
|
+
const path = normalizeModulePath(id)
|
|
191
|
+
const normRoot = normalizeModulePath(root).replace(/\/$/, '')
|
|
192
|
+
if (path.includes('.pnpm/'))
|
|
193
|
+
return collapsePnpmPath(path, pnpmCollapse)
|
|
194
|
+
if (!normRoot)
|
|
195
|
+
return path
|
|
196
|
+
if (path === normRoot)
|
|
197
|
+
return '.'
|
|
198
|
+
if (path.startsWith(`${normRoot}/`)) {
|
|
199
|
+
const rest = path.slice(normRoot.length + 1)
|
|
200
|
+
return rest.startsWith('.') ? rest : `./${rest}`
|
|
201
|
+
}
|
|
202
|
+
// Outside the root: synthesize `../`, but bail to absolute past `maxUp` hops.
|
|
203
|
+
const rootParts = normRoot.split('/')
|
|
204
|
+
const pathParts = path.split('/')
|
|
205
|
+
let common = 0
|
|
206
|
+
while (common < rootParts.length && common < pathParts.length && rootParts[common] === pathParts[common])
|
|
207
|
+
common++
|
|
208
|
+
const up = rootParts.length - common
|
|
209
|
+
if (up > maxUp || common === 0)
|
|
210
|
+
return path
|
|
211
|
+
return `${'../'.repeat(up)}${pathParts.slice(common).join('/')}`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ReadablePath {
|
|
215
|
+
/** Display path. */
|
|
216
|
+
path: string
|
|
217
|
+
/** Resolved package name when the path lives in `node_modules`. */
|
|
218
|
+
moduleName?: string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Build a display path plus resolved package name for a module id.
|
|
223
|
+
*
|
|
224
|
+
* Combines {@link relativeModulePath} for the display path with
|
|
225
|
+
* {@link getModuleNameFromPath} (only for `node_modules` paths) for the package
|
|
226
|
+
* name.
|
|
227
|
+
*
|
|
228
|
+
* @param path - The module id or file path.
|
|
229
|
+
* @param root - The project root used to relativize the display path.
|
|
230
|
+
* @param options - Forwarded to {@link relativeModulePath}.
|
|
231
|
+
* @returns A {@link ReadablePath} with `path` and, for dependencies, `moduleName`.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* parseReadablePath('/x/node_modules/foo/index.js', '/x')
|
|
235
|
+
* // → { path: './node_modules/foo/index.js', moduleName: 'foo' }
|
|
236
|
+
*/
|
|
237
|
+
export function parseReadablePath(path: string, root: string, options?: RelativeModulePathOptions): ReadablePath {
|
|
238
|
+
const moduleName = isNodeModulePath(path) ? getModuleNameFromPath(path) : undefined
|
|
239
|
+
return {
|
|
240
|
+
path: relativeModulePath(path, root, options),
|
|
241
|
+
moduleName,
|
|
242
|
+
}
|
|
243
|
+
}
|