@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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/a11y/cli.ts +73 -0
  4. package/a11y/index.ts +13 -0
  5. package/a11y/scan.ts +127 -0
  6. package/components/Action/ActionButton.stories.ts +56 -0
  7. package/components/Action/ActionButton.vue +57 -0
  8. package/components/Action/ActionDarkToggle.stories.ts +31 -0
  9. package/components/Action/ActionDarkToggle.vue +87 -0
  10. package/components/Action/ActionIconButton.stories.ts +47 -0
  11. package/components/Action/ActionIconButton.vue +47 -0
  12. package/components/Display/DisplayAvatar.stories.ts +36 -0
  13. package/components/Display/DisplayAvatar.vue +58 -0
  14. package/components/Display/DisplayBadge.stories.ts +31 -0
  15. package/components/Display/DisplayBadge.vue +98 -0
  16. package/components/Display/DisplayBytes.stories.ts +28 -0
  17. package/components/Display/DisplayBytes.vue +30 -0
  18. package/components/Display/DisplayDate.stories.ts +37 -0
  19. package/components/Display/DisplayDate.vue +29 -0
  20. package/components/Display/DisplayDonut.stories.ts +26 -0
  21. package/components/Display/DisplayDonut.vue +46 -0
  22. package/components/Display/DisplayDuration.stories.ts +28 -0
  23. package/components/Display/DisplayDuration.vue +28 -0
  24. package/components/Display/DisplayFileIcon.stories.ts +27 -0
  25. package/components/Display/DisplayFileIcon.vue +30 -0
  26. package/components/Display/DisplayFilePath.stories.ts +30 -0
  27. package/components/Display/DisplayFilePath.vue +61 -0
  28. package/components/Display/DisplayKbd.stories.ts +26 -0
  29. package/components/Display/DisplayKbd.vue +27 -0
  30. package/components/Display/DisplayKeyValue.stories.ts +56 -0
  31. package/components/Display/DisplayKeyValue.vue +51 -0
  32. package/components/Display/DisplayLabel.stories.ts +27 -0
  33. package/components/Display/DisplayLabel.vue +33 -0
  34. package/components/Display/DisplayNumber.stories.ts +27 -0
  35. package/components/Display/DisplayNumber.vue +24 -0
  36. package/components/Display/DisplayNumberBadge.stories.ts +26 -0
  37. package/components/Display/DisplayNumberBadge.vue +22 -0
  38. package/components/Display/DisplayPackageName.stories.ts +26 -0
  39. package/components/Display/DisplayPackageName.vue +49 -0
  40. package/components/Display/DisplayProgressBar.stories.ts +29 -0
  41. package/components/Display/DisplayProgressBar.vue +90 -0
  42. package/components/Display/DisplayProportionBar.stories.ts +40 -0
  43. package/components/Display/DisplayProportionBar.vue +43 -0
  44. package/components/Display/DisplaySafeImage.stories.ts +43 -0
  45. package/components/Display/DisplaySafeImage.vue +30 -0
  46. package/components/Display/DisplayStatusPill.stories.ts +34 -0
  47. package/components/Display/DisplayStatusPill.vue +42 -0
  48. package/components/Display/DisplayTree.stories.ts +76 -0
  49. package/components/Display/DisplayTree.vue +102 -0
  50. package/components/Display/DisplayVersion.stories.ts +25 -0
  51. package/components/Display/DisplayVersion.vue +21 -0
  52. package/components/Feedback/FeedbackEmptyState.stories.ts +38 -0
  53. package/components/Feedback/FeedbackEmptyState.vue +21 -0
  54. package/components/Feedback/FeedbackLoading.stories.ts +23 -0
  55. package/components/Feedback/FeedbackLoading.vue +21 -0
  56. package/components/Feedback/FeedbackSpinner.stories.ts +25 -0
  57. package/components/Feedback/FeedbackSpinner.vue +22 -0
  58. package/components/Feedback/FeedbackTip.stories.ts +34 -0
  59. package/components/Feedback/FeedbackTip.vue +29 -0
  60. package/components/Feedback/FeedbackToasts.stories.ts +40 -0
  61. package/components/Feedback/FeedbackToasts.vue +105 -0
  62. package/components/Form/FormCheckbox.stories.ts +36 -0
  63. package/components/Form/FormCheckbox.vue +30 -0
  64. package/components/Form/FormCombobox.stories.ts +35 -0
  65. package/components/Form/FormCombobox.vue +83 -0
  66. package/components/Form/FormField.stories.ts +56 -0
  67. package/components/Form/FormField.vue +36 -0
  68. package/components/Form/FormNumberInput.stories.ts +47 -0
  69. package/components/Form/FormNumberInput.vue +85 -0
  70. package/components/Form/FormRadioGroup.stories.ts +47 -0
  71. package/components/Form/FormRadioGroup.vue +43 -0
  72. package/components/Form/FormSearchField.stories.ts +22 -0
  73. package/components/Form/FormSearchField.vue +32 -0
  74. package/components/Form/FormSelect.stories.ts +47 -0
  75. package/components/Form/FormSelect.vue +56 -0
  76. package/components/Form/FormSwitch.stories.ts +36 -0
  77. package/components/Form/FormSwitch.vue +26 -0
  78. package/components/Form/FormTextInput.stories.ts +39 -0
  79. package/components/Form/FormTextInput.vue +51 -0
  80. package/components/Form/FormTextarea.stories.ts +47 -0
  81. package/components/Form/FormTextarea.vue +32 -0
  82. package/components/Layout/LayoutBreadcrumb.stories.ts +54 -0
  83. package/components/Layout/LayoutBreadcrumb.vue +54 -0
  84. package/components/Layout/LayoutCard.stories.ts +31 -0
  85. package/components/Layout/LayoutCard.vue +21 -0
  86. package/components/Layout/LayoutDataTable.stories.ts +77 -0
  87. package/components/Layout/LayoutDataTable.vue +145 -0
  88. package/components/Layout/LayoutExpandableList.stories.ts +28 -0
  89. package/components/Layout/LayoutExpandableList.vue +94 -0
  90. package/components/Layout/LayoutPanelGrids.stories.ts +28 -0
  91. package/components/Layout/LayoutPanelGrids.vue +26 -0
  92. package/components/Layout/LayoutSectionBlock.stories.ts +37 -0
  93. package/components/Layout/LayoutSectionBlock.vue +37 -0
  94. package/components/Layout/LayoutSideNav.stories.ts +33 -0
  95. package/components/Layout/LayoutSideNav.vue +48 -0
  96. package/components/Layout/LayoutSplitPane.stories.ts +44 -0
  97. package/components/Layout/LayoutSplitPane.vue +30 -0
  98. package/components/Layout/LayoutTabs.stories.ts +43 -0
  99. package/components/Layout/LayoutTabs.vue +56 -0
  100. package/components/Layout/LayoutToolbar.stories.ts +60 -0
  101. package/components/Layout/LayoutToolbar.vue +28 -0
  102. package/components/Layout/LayoutVirtualList.stories.ts +30 -0
  103. package/components/Layout/LayoutVirtualList.vue +82 -0
  104. package/components/Overlay/OverlayDrawer.stories.ts +47 -0
  105. package/components/Overlay/OverlayDrawer.vue +58 -0
  106. package/components/Overlay/OverlayDropdown.stories.ts +25 -0
  107. package/components/Overlay/OverlayDropdown.vue +30 -0
  108. package/components/Overlay/OverlayDropdownItem.stories.ts +26 -0
  109. package/components/Overlay/OverlayDropdownItem.vue +31 -0
  110. package/components/Overlay/OverlayDropdownLabel.vue +9 -0
  111. package/components/Overlay/OverlayDropdownSeparator.vue +7 -0
  112. package/components/Overlay/OverlayModal.stories.ts +33 -0
  113. package/components/Overlay/OverlayModal.vue +48 -0
  114. package/components/Overlay/OverlayTooltip.stories.ts +33 -0
  115. package/components/Overlay/OverlayTooltip.vue +38 -0
  116. package/composables/colorScheme.ts +58 -0
  117. package/composables/toast.ts +81 -0
  118. package/package.json +99 -0
  119. package/skills/antfu-design/SKILL.md +65 -0
  120. package/skills/antfu-design/references/advanced-patterns.md +39 -0
  121. package/skills/antfu-design/references/best-practices.md +54 -0
  122. package/skills/antfu-design/references/core-components.md +72 -0
  123. package/skills/antfu-design/references/core-setup.md +56 -0
  124. package/skills/antfu-design/references/core-tokens.md +100 -0
  125. package/skills/antfu-design/references/features-data-presentation.md +27 -0
  126. package/splitpanes.d.ts +70 -0
  127. package/styles/animations.css +47 -0
  128. package/styles/base.css +31 -0
  129. package/styles/floating-vue.css +28 -0
  130. package/styles/index.css +7 -0
  131. package/styles/reka-ui.css +112 -0
  132. package/styles/scrollbar.css +24 -0
  133. package/styles/splitpanes.css +61 -0
  134. package/unocss/colors.ts +127 -0
  135. package/unocss/index.ts +99 -0
  136. package/unocss/options.ts +31 -0
  137. package/unocss/patterns.ts +38 -0
  138. package/unocss/rules.ts +26 -0
  139. package/unocss/severity.ts +16 -0
  140. package/unocss/shortcuts.ts +68 -0
  141. package/utils/color.ts +328 -0
  142. package/utils/contrast.ts +118 -0
  143. package/utils/format.ts +389 -0
  144. package/utils/icon.ts +200 -0
  145. package/utils/index.ts +13 -0
  146. package/utils/keybinding.ts +199 -0
  147. package/utils/misc.ts +141 -0
  148. package/utils/path.ts +243 -0
  149. package/utils/semver.ts +147 -0
  150. 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
+ }