docs-please 0.2.0-beta.0 → 0.2.2-beta.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 (51) hide show
  1. package/app/app.config.ts +9 -1
  2. package/app/assets/css/main.css +2 -2
  3. package/app/components/app/AppHeader.vue +92 -37
  4. package/app/components/app/AppHeaderCenter.vue +3 -0
  5. package/app/components/app/AppHeaderLogo.vue +43 -0
  6. package/app/components/app/AppHeaderSearch.vue +3 -0
  7. package/app/components/app/AppSearch.vue +189 -0
  8. package/app/components/app/AppSearchButton.vue +51 -0
  9. package/app/components/content/Accordion.vue +117 -0
  10. package/app/components/content/AccordionItem.vue +27 -0
  11. package/app/components/content/Badge.vue +42 -0
  12. package/app/components/content/Collapsible.vue +56 -0
  13. package/app/components/content/ProseKbd.vue +9 -0
  14. package/app/components/content/ProseTable.vue +2 -2
  15. package/app/components/content/ProseTh.vue +1 -1
  16. package/app/components/content/ProseTr.vue +1 -1
  17. package/app/components/ui/command/Command.vue +86 -0
  18. package/app/components/ui/command/CommandDialog.vue +21 -0
  19. package/app/components/ui/command/CommandEmpty.vue +23 -0
  20. package/app/components/ui/command/CommandGroup.vue +44 -0
  21. package/app/components/ui/command/CommandInput.vue +35 -0
  22. package/app/components/ui/command/CommandItem.vue +75 -0
  23. package/app/components/ui/command/CommandList.vue +21 -0
  24. package/app/components/ui/command/CommandSeparator.vue +20 -0
  25. package/app/components/ui/command/CommandShortcut.vue +14 -0
  26. package/app/components/ui/command/index.ts +25 -0
  27. package/app/components/ui/dialog/Dialog.vue +19 -0
  28. package/app/components/ui/dialog/DialogClose.vue +15 -0
  29. package/app/components/ui/dialog/DialogContent.vue +53 -0
  30. package/app/components/ui/dialog/DialogDescription.vue +23 -0
  31. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  32. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  33. package/app/components/ui/dialog/DialogOverlay.vue +21 -0
  34. package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
  35. package/app/components/ui/dialog/DialogTitle.vue +23 -0
  36. package/app/components/ui/dialog/DialogTrigger.vue +15 -0
  37. package/app/components/ui/dialog/index.ts +10 -0
  38. package/app/components/ui/kbd/Kbd.vue +21 -0
  39. package/app/components/ui/kbd/KbdGroup.vue +17 -0
  40. package/app/components/ui/kbd/index.ts +2 -0
  41. package/app/composables/useContentSearch.ts +52 -0
  42. package/app/layouts/default.vue +1 -0
  43. package/app/layouts/docs.vue +1 -0
  44. package/app/utils/navigation.ts +7 -0
  45. package/app/utils/prerender.ts +12 -0
  46. package/nuxt.config.ts +4 -0
  47. package/nuxt.schema.ts +44 -0
  48. package/package.json +9 -3
  49. package/server/routes/raw/[...slug].md.get.ts +45 -0
  50. package/utils/git.ts +59 -0
  51. package/utils/meta.ts +29 -0
package/app/app.config.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  export default defineAppConfig({
2
2
  docs: {
3
3
  title: 'Documentation',
4
- description: 'Documentation site powered by @pleaseai/docs',
4
+ description: 'Documentation site powered by docs-please',
5
5
  url: '',
6
+ header: {
7
+ title: '',
8
+ logo: {
9
+ light: '',
10
+ dark: '',
11
+ alt: '',
12
+ },
13
+ },
6
14
  github: {
7
15
  owner: '',
8
16
  name: '',
@@ -354,11 +354,11 @@
354
354
  }
355
355
 
356
356
  .prose th {
357
- @apply border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right;
357
+ @apply px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right;
358
358
  }
359
359
 
360
360
  .prose td {
361
- @apply border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right;
361
+ @apply px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right;
362
362
  }
363
363
 
364
364
  .prose hr {
@@ -1,57 +1,112 @@
1
1
  <script setup lang="ts">
2
- import { Moon, Sun } from 'lucide-vue-next'
2
+ import { Github, Moon, Sun } from 'lucide-vue-next'
3
3
  import { Button } from '~/components/ui/button'
4
+ import { Separator } from '~/components/ui/separator'
4
5
 
6
+ const appConfig = useAppConfig()
5
7
  const colorMode = useColorMode()
6
8
 
9
+ const isDark = computed(() => colorMode.value === 'dark')
10
+
7
11
  function toggleColorMode() {
8
- colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
12
+ colorMode.preference = isDark.value ? 'light' : 'dark'
9
13
  }
10
14
 
11
- const appConfig = useAppConfig()
12
- const title = computed(() => appConfig.docs?.title || 'Docs')
15
+ const links = computed(() => {
16
+ const result = []
17
+ if (appConfig.docs?.github?.url) {
18
+ result.push({
19
+ icon: Github,
20
+ to: appConfig.docs.github.url,
21
+ target: '_blank',
22
+ ariaLabel: 'GitHub',
23
+ })
24
+ }
25
+ return result
26
+ })
13
27
  </script>
14
28
 
15
29
  <template>
16
30
  <header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
31
+ <!-- Skip to main content link for accessibility -->
32
+ <a
33
+ href="#main-content"
34
+ class="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-[100] focus:rounded-md focus:border focus:bg-background focus:px-4 focus:py-2"
35
+ >
36
+ Skip to main content
37
+ </a>
38
+
17
39
  <div class="container flex h-14 items-center">
18
40
  <!-- Logo -->
19
- <div class="mr-4 flex">
20
- <NuxtLink
21
- to="/"
22
- class="flex items-center space-x-2"
23
- >
24
- <span class="font-bold">{{ title }}</span>
25
- </NuxtLink>
41
+ <NuxtLink
42
+ to="/"
43
+ class="mr-4 flex items-center gap-2"
44
+ :aria-label="`${appConfig.docs?.title || 'Home'} - Go to homepage`"
45
+ >
46
+ <AppHeaderLogo class="h-6 w-auto shrink-0" />
47
+ </NuxtLink>
48
+
49
+ <!-- Center (Search) -->
50
+ <div class="hidden flex-1 justify-center lg:flex">
51
+ <AppHeaderCenter />
26
52
  </div>
27
53
 
28
- <!-- Navigation -->
29
- <nav class="flex flex-1 items-center space-x-6 text-sm font-medium">
30
- <NuxtLink
31
- to="/docs"
32
- class="text-muted-foreground transition-colors hover:text-foreground"
33
- >
34
- Documentation
35
- </NuxtLink>
36
- </nav>
37
-
38
- <!-- Actions -->
39
- <div class="flex items-center space-x-2">
40
- <Button
41
- variant="ghost"
42
- size="icon"
43
- @click="toggleColorMode"
44
- >
45
- <Sun
46
- v-if="colorMode.value === 'dark'"
47
- class="size-5"
48
- />
49
- <Moon
50
- v-else
51
- class="size-5"
54
+ <!-- Spacer for mobile -->
55
+ <div class="flex-1 lg:hidden" />
56
+
57
+ <!-- Right Actions -->
58
+ <div class="flex items-center gap-1">
59
+ <!-- Search button (mobile) -->
60
+ <AppHeaderSearch class="lg:hidden" />
61
+
62
+ <!-- Color mode toggle -->
63
+ <ClientOnly>
64
+ <Button
65
+ variant="ghost"
66
+ size="icon"
67
+ @click="toggleColorMode"
68
+ >
69
+ <Moon
70
+ v-if="isDark"
71
+ class="size-5"
72
+ />
73
+ <Sun
74
+ v-else
75
+ class="size-5"
76
+ />
77
+ <span class="sr-only">Toggle theme</span>
78
+ </Button>
79
+
80
+ <template #fallback>
81
+ <div class="size-8 animate-pulse rounded-md bg-muted" />
82
+ </template>
83
+ </ClientOnly>
84
+
85
+ <!-- External links (GitHub, etc.) -->
86
+ <template v-if="links.length">
87
+ <Separator
88
+ orientation="vertical"
89
+ class="mx-1 h-6"
52
90
  />
53
- <span class="sr-only">Toggle theme</span>
54
- </Button>
91
+ <Button
92
+ v-for="link in links"
93
+ :key="link.to"
94
+ variant="ghost"
95
+ size="icon"
96
+ as-child
97
+ >
98
+ <a
99
+ :href="link.to"
100
+ :target="link.target"
101
+ :aria-label="link.ariaLabel"
102
+ >
103
+ <component
104
+ :is="link.icon"
105
+ class="size-5"
106
+ />
107
+ </a>
108
+ </Button>
109
+ </template>
55
110
  </div>
56
111
  </div>
57
112
  </header>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <AppSearchButton />
3
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ const appConfig = useAppConfig()
3
+ const colorMode = useColorMode()
4
+
5
+ const logoSrc = computed(() => {
6
+ const header = appConfig.docs?.header
7
+ if (!header?.logo) return null
8
+
9
+ if (colorMode.value === 'dark' && header.logo.dark) {
10
+ return header.logo.dark
11
+ }
12
+ return header.logo.light || header.logo.dark
13
+ })
14
+
15
+ const logoAlt = computed(() => {
16
+ return appConfig.docs?.header?.logo?.alt || appConfig.docs?.title || 'Logo'
17
+ })
18
+
19
+ const title = computed(() => {
20
+ return appConfig.docs?.header?.title || appConfig.docs?.title || 'Documentation'
21
+ })
22
+ </script>
23
+
24
+ <template>
25
+ <ClientOnly>
26
+ <img
27
+ v-if="logoSrc"
28
+ :src="logoSrc"
29
+ :alt="logoAlt"
30
+ class="h-6 w-auto shrink-0"
31
+ >
32
+ <span
33
+ v-else
34
+ class="font-bold"
35
+ >
36
+ {{ title }}
37
+ </span>
38
+
39
+ <template #fallback>
40
+ <span class="font-bold">{{ title }}</span>
41
+ </template>
42
+ </ClientOnly>
43
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <AppSearchButton collapsed />
3
+ </template>
@@ -0,0 +1,189 @@
1
+ <script setup lang="ts">
2
+ import { ArrowRight, FileText, Monitor, Moon, Sun } from 'lucide-vue-next'
3
+ import { useFuse } from '@vueuse/integrations/useFuse'
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ CommandSeparator,
12
+ } from '~/components/ui/command'
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from '~/components/ui/dialog'
20
+ import { flattenNavigation, useContentSearch } from '~/composables/useContentSearch'
21
+
22
+ const router = useRouter()
23
+ const colorMode = useColorMode()
24
+ const { open } = useContentSearch()
25
+ const { data: navigation } = await useNavigation()
26
+
27
+ const searchTerm = ref('')
28
+
29
+ // Flatten navigation for search
30
+ const searchItems = computed(() => flattenNavigation(navigation.value))
31
+
32
+ // Fuse search
33
+ const { results } = useFuse(searchTerm, searchItems, {
34
+ fuseOptions: {
35
+ keys: ['label', 'prefix'],
36
+ threshold: 0.3,
37
+ },
38
+ matchAllWhenSearchEmpty: true,
39
+ })
40
+
41
+ const filteredItems = computed(() => {
42
+ if (!searchTerm.value) {
43
+ return searchItems.value
44
+ }
45
+ return results.value.map(r => r.item)
46
+ })
47
+
48
+ // Group items by prefix (parent title)
49
+ const groupedItems = computed(() => {
50
+ const groups = new Map<string, typeof searchItems.value>()
51
+
52
+ for (const item of filteredItems.value) {
53
+ const groupKey = item.prefix || 'Pages'
54
+ if (!groups.has(groupKey)) {
55
+ groups.set(groupKey, [])
56
+ }
57
+ groups.get(groupKey)!.push(item)
58
+ }
59
+
60
+ return Array.from(groups.entries()).map(([label, items]) => ({
61
+ label,
62
+ items,
63
+ }))
64
+ })
65
+
66
+ function runCommand(command: () => unknown) {
67
+ open.value = false
68
+ searchTerm.value = ''
69
+ command()
70
+ }
71
+
72
+ function handleColorMode(mode: 'system' | 'light' | 'dark') {
73
+ runCommand(() => {
74
+ colorMode.preference = mode
75
+ })
76
+ }
77
+
78
+ // Keyboard shortcut
79
+ onMounted(() => {
80
+ const down = (e: KeyboardEvent) => {
81
+ if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') {
82
+ if (
83
+ (e.target instanceof HTMLElement && e.target.isContentEditable)
84
+ || e.target instanceof HTMLInputElement
85
+ || e.target instanceof HTMLTextAreaElement
86
+ || e.target instanceof HTMLSelectElement
87
+ ) {
88
+ return
89
+ }
90
+ e.preventDefault()
91
+ open.value = !open.value
92
+ }
93
+ }
94
+ document.addEventListener('keydown', down)
95
+ onUnmounted(() => document.removeEventListener('keydown', down))
96
+ })
97
+ </script>
98
+
99
+ <template>
100
+ <Dialog v-model:open="open">
101
+ <DialogContent
102
+ class="overflow-hidden p-0 shadow-lg"
103
+ :show-close-button="false"
104
+ >
105
+ <DialogHeader class="sr-only">
106
+ <DialogTitle>Search documentation</DialogTitle>
107
+ <DialogDescription>Search for pages in the documentation</DialogDescription>
108
+ </DialogHeader>
109
+ <Command
110
+ v-model:search-term="searchTerm"
111
+ class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5"
112
+ >
113
+ <CommandInput placeholder="Search documentation..." />
114
+ <CommandList class="max-h-[300px]">
115
+ <CommandEmpty>No results found.</CommandEmpty>
116
+
117
+ <!-- Navigation groups -->
118
+ <CommandGroup
119
+ v-for="group in groupedItems"
120
+ :key="group.label"
121
+ :heading="group.label"
122
+ >
123
+ <CommandItem
124
+ v-for="item in group.items"
125
+ :key="item.id"
126
+ :value="item.label"
127
+ @select="() => runCommand(() => router.push(item.to!))"
128
+ >
129
+ <FileText
130
+ v-if="item.isDoc"
131
+ class="mr-2 size-4"
132
+ />
133
+ <ArrowRight
134
+ v-else
135
+ class="mr-2 size-4"
136
+ />
137
+ <span>{{ item.label }}</span>
138
+ </CommandItem>
139
+ </CommandGroup>
140
+
141
+ <CommandSeparator />
142
+
143
+ <!-- Theme group -->
144
+ <CommandGroup heading="Theme">
145
+ <CommandItem
146
+ value="System theme"
147
+ @select="() => handleColorMode('system')"
148
+ >
149
+ <Monitor class="mr-2 size-4" />
150
+ <span>System</span>
151
+ <span
152
+ v-if="colorMode.preference === 'system'"
153
+ class="ml-auto text-xs text-muted-foreground"
154
+ >
155
+ Active
156
+ </span>
157
+ </CommandItem>
158
+ <CommandItem
159
+ value="Light theme"
160
+ @select="() => handleColorMode('light')"
161
+ >
162
+ <Sun class="mr-2 size-4" />
163
+ <span>Light</span>
164
+ <span
165
+ v-if="colorMode.preference === 'light'"
166
+ class="ml-auto text-xs text-muted-foreground"
167
+ >
168
+ Active
169
+ </span>
170
+ </CommandItem>
171
+ <CommandItem
172
+ value="Dark theme"
173
+ @select="() => handleColorMode('dark')"
174
+ >
175
+ <Moon class="mr-2 size-4" />
176
+ <span>Dark</span>
177
+ <span
178
+ v-if="colorMode.preference === 'dark'"
179
+ class="ml-auto text-xs text-muted-foreground"
180
+ >
181
+ Active
182
+ </span>
183
+ </CommandItem>
184
+ </CommandGroup>
185
+ </CommandList>
186
+ </Command>
187
+ </DialogContent>
188
+ </Dialog>
189
+ </template>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { Search } from 'lucide-vue-next'
3
+ import { Button } from '~/components/ui/button'
4
+ import { Kbd, KbdGroup } from '~/components/ui/kbd'
5
+ import { useContentSearch } from '~/composables/useContentSearch'
6
+
7
+ interface Props {
8
+ collapsed?: boolean
9
+ }
10
+
11
+ withDefaults(defineProps<Props>(), {
12
+ collapsed: false,
13
+ })
14
+
15
+ const { open } = useContentSearch()
16
+
17
+ const isMac = computed(() => {
18
+ if (import.meta.server) return true
19
+ return navigator.platform.toUpperCase().includes('MAC')
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <Button
25
+ v-if="collapsed"
26
+ variant="ghost"
27
+ size="icon"
28
+ aria-label="Search documentation"
29
+ @click="open = true"
30
+ >
31
+ <Search class="size-5" />
32
+ </Button>
33
+ <Button
34
+ v-else
35
+ variant="outline"
36
+ class="relative h-8 w-full max-w-sm justify-start bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-48 lg:w-56 xl:w-64"
37
+ @click="open = true"
38
+ >
39
+ <Search class="mr-2 size-4" />
40
+ <span class="hidden lg:inline-flex">Search documentation...</span>
41
+ <span class="inline-flex lg:hidden">Search...</span>
42
+ <div class="absolute right-1.5 top-1.5 hidden gap-1 sm:flex">
43
+ <ClientOnly>
44
+ <KbdGroup>
45
+ <Kbd class="border">{{ isMac ? '⌘' : 'Ctrl' }}</Kbd>
46
+ <Kbd class="border">K</Kbd>
47
+ </KbdGroup>
48
+ </ClientOnly>
49
+ </div>
50
+ </Button>
51
+ </template>
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes, VNode } from 'vue'
3
+ import { computed, onBeforeUpdate, ref } from 'vue'
4
+ import { cn } from '~/lib/utils'
5
+ import {
6
+ Accordion as UIAccordion,
7
+ AccordionContent as UIAccordionContent,
8
+ AccordionItem as UIAccordionItem,
9
+ AccordionTrigger as UIAccordionTrigger,
10
+ } from '~/components/ui/accordion'
11
+
12
+ export interface AccordionProps {
13
+ /**
14
+ * Accordion type - single or multiple items open at once.
15
+ * @default 'multiple'
16
+ */
17
+ type?: 'single' | 'multiple'
18
+ /**
19
+ * Default open items (comma-separated indices for multiple, single index for single).
20
+ * @example '0,1' or '0'
21
+ */
22
+ defaultValue?: string
23
+ class?: HTMLAttributes['class']
24
+ }
25
+
26
+ const props = withDefaults(defineProps<AccordionProps>(), {
27
+ type: 'multiple',
28
+ })
29
+
30
+ const slots = defineSlots<{
31
+ default(): VNode[]
32
+ }>()
33
+
34
+ const rerenderCount = ref(1)
35
+
36
+ interface AccordionItemData {
37
+ value: string
38
+ label: string
39
+ icon?: string
40
+ component: VNode
41
+ }
42
+
43
+ const items = computed<AccordionItemData[]>(() => {
44
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
45
+ rerenderCount.value
46
+
47
+ let counter = 0
48
+ function transformSlot(slot: VNode): AccordionItemData | null {
49
+ if (typeof slot.type === 'symbol') {
50
+ return null
51
+ }
52
+
53
+ const slotProps = slot.props as { label?: string, icon?: string } | null
54
+ if (!slotProps?.label) {
55
+ return null
56
+ }
57
+
58
+ return {
59
+ value: String(counter++),
60
+ label: slotProps.label,
61
+ icon: slotProps.icon,
62
+ component: slot,
63
+ }
64
+ }
65
+
66
+ function flattenSlots(slot: VNode): VNode[] {
67
+ if (typeof slot.type === 'symbol') {
68
+ const children = slot.children as VNode[] | null
69
+ return children?.flatMap(flattenSlots) ?? []
70
+ }
71
+ return [slot]
72
+ }
73
+
74
+ return slots.default?.()?.flatMap(flattenSlots).map(transformSlot).filter((item): item is AccordionItemData => item !== null) || []
75
+ })
76
+
77
+ const defaultValue = computed(() => {
78
+ if (props.defaultValue) {
79
+ return props.type === 'multiple'
80
+ ? props.defaultValue.split(',').map(v => v.trim())
81
+ : props.defaultValue
82
+ }
83
+ return props.type === 'multiple' ? [] : undefined
84
+ })
85
+
86
+ onBeforeUpdate(() => rerenderCount.value++)
87
+ </script>
88
+
89
+ <template>
90
+ <UIAccordion
91
+ :type="type"
92
+ :default-value="defaultValue"
93
+ :class="cn('mt-6', props.class)"
94
+ collapsible
95
+ >
96
+ <UIAccordionItem
97
+ v-for="item in items"
98
+ :key="item.value"
99
+ :value="item.value"
100
+ >
101
+ <UIAccordionTrigger class="items-center">
102
+ <div class="flex items-center text-left">
103
+ <Icon
104
+ v-if="item.icon"
105
+ :name="item.icon"
106
+ class="size-4 mr-2 shrink-0"
107
+ aria-hidden="true"
108
+ />
109
+ <span>{{ item.label }}</span>
110
+ </div>
111
+ </UIAccordionTrigger>
112
+ <UIAccordionContent class="text-left">
113
+ <component :is="item.component" />
114
+ </UIAccordionContent>
115
+ </UIAccordionItem>
116
+ </UIAccordion>
117
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes, VNode } from 'vue'
3
+ import { cn } from '~/lib/utils'
4
+
5
+ export interface AccordionItemProps {
6
+ /**
7
+ * The label displayed in the accordion trigger.
8
+ */
9
+ label: string
10
+ /**
11
+ * Optional icon name for the accordion item.
12
+ */
13
+ icon?: string
14
+ class?: HTMLAttributes['class']
15
+ }
16
+
17
+ const props = defineProps<AccordionItemProps>()
18
+ defineSlots<{
19
+ default(): VNode[]
20
+ }>()
21
+ </script>
22
+
23
+ <template>
24
+ <div :class="cn('text-left', props.class)">
25
+ <slot />
26
+ </div>
27
+ </template>
@@ -0,0 +1,42 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes, VNode } from 'vue'
3
+ import { cn } from '~/lib/utils'
4
+
5
+ export interface BadgeProps {
6
+ /**
7
+ * Badge color variant.
8
+ * @default 'primary'
9
+ */
10
+ color?: 'primary' | 'secondary' | 'success' | 'warning' | 'destructive' | 'muted'
11
+ class?: HTMLAttributes['class']
12
+ }
13
+
14
+ const props = withDefaults(defineProps<BadgeProps>(), {
15
+ color: 'primary',
16
+ })
17
+
18
+ defineSlots<{
19
+ default(): VNode[]
20
+ }>()
21
+
22
+ const colorClasses: Record<string, string> = {
23
+ primary: 'bg-primary/10 text-primary border-primary/20',
24
+ secondary: 'bg-secondary text-secondary-foreground border-secondary',
25
+ success: 'bg-green-500/10 text-green-600 border-green-500/20 dark:text-green-400',
26
+ warning: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20 dark:text-yellow-400',
27
+ destructive: 'bg-destructive/10 text-destructive border-destructive/20',
28
+ muted: 'bg-muted text-muted-foreground border-border',
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <span
34
+ :class="cn(
35
+ 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
36
+ colorClasses[color],
37
+ props.class,
38
+ )"
39
+ >
40
+ <slot mdc-unwrap="p" />
41
+ </span>
42
+ </template>