mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-20 15:01:46 +01:00
feat(module): devtools integration (#2196)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
148
src/devtools/meta.ts
Normal file
148
src/devtools/meta.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { ViteDevServer } from 'vite'
|
||||
import { kebabCase, camelCase } from 'scule'
|
||||
import defu from 'defu'
|
||||
import fs from 'node:fs'
|
||||
import type { Resolver } from '@nuxt/kit'
|
||||
import type { ComponentMeta } from 'vue-component-meta'
|
||||
import type { DevtoolsMeta } from '../runtime/composables/extendDevtoolsMeta'
|
||||
import type { ModuleOptions } from '../module'
|
||||
|
||||
export type Component = {
|
||||
slug: string
|
||||
label: string
|
||||
meta?: ComponentMeta & { devtools: DevtoolsMeta<any> }
|
||||
defaultVariants: Record<string, any>
|
||||
}
|
||||
|
||||
const devtoolsComponentMeta: Record<string, any> = {}
|
||||
|
||||
function extractDevtoolsMeta(code: string): string | null {
|
||||
const match = code.match(/extendDevtoolsMeta(?:<.*?>)?\(/)
|
||||
if (!match) return null
|
||||
|
||||
const startIndex = code.indexOf(match[0]) + match[0].length
|
||||
let openBraceCount = 0
|
||||
let closeBraceCount = 0
|
||||
let endIndex = startIndex
|
||||
|
||||
for (let i = startIndex; i < code.length; i++) {
|
||||
if (code[i] === '{') openBraceCount++
|
||||
if (code[i] === '}') closeBraceCount++
|
||||
|
||||
if (openBraceCount > 0 && openBraceCount === closeBraceCount) {
|
||||
endIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
// Return only the object inside extendDevtoolsMeta
|
||||
return code.slice(startIndex, endIndex).trim()
|
||||
}
|
||||
|
||||
// A Plugin to parse additional metadata for the Nuxt UI Devtools.
|
||||
export function devtoolsMetaPlugin({ resolve, options, templates }: { resolve: Resolver['resolve'], options: ModuleOptions, templates: Record<string, any> }) {
|
||||
return {
|
||||
name: 'ui-devtools-component-meta',
|
||||
enforce: 'pre' as const,
|
||||
|
||||
async transform(code: string, id: string) {
|
||||
if (!id.match(/\/runtime\/components\/\w+.vue/)) return
|
||||
const fileName = id.split('/')[id.split('/').length - 1]
|
||||
|
||||
if (code && fileName) {
|
||||
const slug = kebabCase(fileName.replace(/\..*/, ''))
|
||||
const match = extractDevtoolsMeta(code)
|
||||
|
||||
if (match) {
|
||||
const metaObject = new Function(`return ${match}`)()
|
||||
devtoolsComponentMeta[slug] = { meta: { devtools: { ...metaObject } } }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code
|
||||
}
|
||||
},
|
||||
|
||||
configureServer(server: ViteDevServer) {
|
||||
server.middlewares.use('/__nuxt_ui__/devtools/api/component-meta', async (_req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
try {
|
||||
const componentMeta = await import('./.component-meta/component-meta')
|
||||
|
||||
const meta = defu(
|
||||
Object.entries(componentMeta.default).reduce((acc, [key, value]: [string, any]) => {
|
||||
if (!key.startsWith('U')) return acc
|
||||
|
||||
const name = key.substring(1)
|
||||
const slug = kebabCase(name)
|
||||
const template = templates?.[camelCase(name)]
|
||||
|
||||
if (devtoolsComponentMeta[slug] === undefined) {
|
||||
const path = resolve(`./runtime/components/${name}.vue`)
|
||||
const code = fs.readFileSync(path, 'utf-8')
|
||||
const match = extractDevtoolsMeta(code)
|
||||
if (match) {
|
||||
const metaObject = new Function(`return ${match}`)()
|
||||
devtoolsComponentMeta[slug] = { meta: { devtools: { ...metaObject } } }
|
||||
} else {
|
||||
devtoolsComponentMeta[slug] = null
|
||||
}
|
||||
}
|
||||
|
||||
value.meta.props = value.meta.props.map((prop: any) => {
|
||||
let defaultValue = prop.default
|
||||
? prop.default
|
||||
: prop?.tags?.find((tag: any) =>
|
||||
tag.name === 'defaultValue'
|
||||
&& !tag.text?.includes('appConfig'))?.text
|
||||
?? template?.defaultVariants?.[prop.name]
|
||||
|
||||
if (typeof defaultValue === 'string') defaultValue = defaultValue?.replaceAll(/["'`]/g, '')
|
||||
if (defaultValue === 'true') defaultValue = true
|
||||
if (defaultValue === 'false') defaultValue = false
|
||||
if (!Number.isNaN(Number.parseInt(defaultValue))) defaultValue = Number.parseInt(defaultValue)
|
||||
|
||||
return {
|
||||
...prop,
|
||||
default: defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
const label = key.replace(/^U/, options.prefix ?? 'U')
|
||||
acc[kebabCase(key.replace(/^U/, ''))] = { ...value, label, slug }
|
||||
return acc
|
||||
}, {} as Record<string, any>),
|
||||
devtoolsComponentMeta
|
||||
)
|
||||
res.end(JSON.stringify(meta))
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch component meta`, error)
|
||||
res.statusCode = 500
|
||||
res.end(JSON.stringify({ error: 'Failed to fetch component meta' }))
|
||||
}
|
||||
})
|
||||
|
||||
server.middlewares.use('/__nuxt_ui__/devtools/api/component-example', async (req, res) => {
|
||||
const query = new URL(req.url!, 'http://localhost').searchParams
|
||||
const name = query.get('component')
|
||||
if (!name) {
|
||||
res.statusCode = 400
|
||||
res.end(JSON.stringify({ error: 'Component name is required' }))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const path = resolve(`./devtools/runtime/examples/${name}.vue`)
|
||||
const source = fs.readFileSync(path, 'utf-8')
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ component: name, source }))
|
||||
} catch (error) {
|
||||
console.error(`Failed to read component source for ${name}:`, error)
|
||||
res.statusCode = 500
|
||||
res.end(JSON.stringify({ error: 'Failed to read component source' }))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/devtools/runtime/DevtoolsRenderer.vue
Normal file
71
src/devtools/runtime/DevtoolsRenderer.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, onMounted, reactive } from 'vue'
|
||||
import { pascalCase } from 'scule'
|
||||
import { defineAsyncComponent, useColorMode, useRoute } from '#imports'
|
||||
|
||||
const route = useRoute()
|
||||
const component = route.query?.example
|
||||
? defineAsyncComponent(() => import(`./examples/${route.query.example}.vue`))
|
||||
: route.params?.slug && defineAsyncComponent(() => import(`../../runtime/components/${pascalCase(route.params.slug as string)}.vue`))
|
||||
|
||||
const state = reactive<{ slots?: any, props?: any }>({})
|
||||
|
||||
function onUpdateRenderer(event: Event & { data?: any }) {
|
||||
state.props = { ...event.data.props }
|
||||
state.slots = { ...event.data.slots }
|
||||
}
|
||||
|
||||
const colorMode = useColorMode()
|
||||
function setColorMode(event: Event & { isDark?: boolean }) {
|
||||
colorMode.preference = event.isDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.parent.addEventListener('nuxt-ui-devtools:update-renderer', onUpdateRenderer)
|
||||
window.parent.addEventListener('nuxt-ui-devtools:set-color-mode', setColorMode)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.parent.removeEventListener('nuxt-ui-devtools:update-renderer', onUpdateRenderer)
|
||||
window.parent.removeEventListener('nuxt-ui-devtools:set-color-mode', setColorMode)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const event: Event = new Event('nuxt-ui-devtools:component-loaded')
|
||||
window.parent.dispatchEvent(event)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!route.query?.example) return
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="ui-devtools-renderer" class="nuxt-ui-component-renderer">
|
||||
<UApp :toaster="null">
|
||||
<component :is="component" v-if="component" v-bind="state.props" :class="state?.slots?.base" :ui="state.slots" />
|
||||
</UApp>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.nuxt-ui-component-renderer {
|
||||
position: 'relative';
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
padding: 32px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' transform='scale(3)'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cpath fill='none' stroke='hsla(0, 0%25, 98%25, 1)' stroke-width='.2' d='M10 0v20ZM0 10h20Z'/%3E%3C/svg%3E");
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.dark .nuxt-ui-component-renderer {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' transform='scale(3)'%3E%3Crect width='100%25' height='100%25' fill='hsl(0, 0%25, 8.5%25)'/%3E%3Cpath fill='none' stroke='hsl(0, 0%25, 11.0%25)' stroke-width='.2' d='M10 0v20ZM0 10h20Z'/%3E%3C/svg%3E");
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
</style>
|
||||
7
src/devtools/runtime/examples/AvatarGroupExample.vue
Normal file
7
src/devtools/runtime/examples/AvatarGroupExample.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<UAvatarGroup>
|
||||
<UAvatar src="https://github.com/benjamincanac.png" alt="Benjamin Canac" />
|
||||
<UAvatar src="https://github.com/romhml.png" alt="Romain Hamel" />
|
||||
<UAvatar src="https://github.com/noook.png" alt="Neil Richter" />
|
||||
</UAvatarGroup>
|
||||
</template>
|
||||
8
src/devtools/runtime/examples/ButtonGroupExample.vue
Normal file
8
src/devtools/runtime/examples/ButtonGroupExample.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<UButtonGroup>
|
||||
<UInput placeholder="Search..." />
|
||||
<UButton color="neutral" variant="outline">
|
||||
Button
|
||||
</UButton>
|
||||
</UButtonGroup>
|
||||
</template>
|
||||
13
src/devtools/runtime/examples/CardExample.vue
Normal file
13
src/devtools/runtime/examples/CardExample.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UCard class="w-96">
|
||||
<template #header>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-8" />
|
||||
</template>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-32" />
|
||||
<template #footer>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-8" />
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
13
src/devtools/runtime/examples/CarouselExample.vue
Normal file
13
src/devtools/runtime/examples/CarouselExample.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<UCarousel
|
||||
v-slot="{ item }"
|
||||
class="basis-1/3"
|
||||
:items="[
|
||||
'https://picsum.photos/320/320?v=1',
|
||||
'https://picsum.photos/320/320?v=2',
|
||||
'https://picsum.photos/320/320?v=3'
|
||||
]"
|
||||
>
|
||||
<img :src="item" class="rounded-lg basis-1/3">
|
||||
</UCarousel>
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/ChipExample.vue
Normal file
5
src/devtools/runtime/examples/ChipExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<UChip>
|
||||
<UAvatar src="https://avatars.githubusercontent.com/u/739984?v=4" />
|
||||
</UChip>
|
||||
</template>
|
||||
8
src/devtools/runtime/examples/CollapsibleExample.vue
Normal file
8
src/devtools/runtime/examples/CollapsibleExample.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<UCollapsible class="w-48">
|
||||
<UButton label="Open Collapse" block />
|
||||
<template #content>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-60" />
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</template>
|
||||
29
src/devtools/runtime/examples/CommandPaletteExample.vue
Normal file
29
src/devtools/runtime/examples/CommandPaletteExample.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const groups = [{
|
||||
id: 'actions',
|
||||
items: [{
|
||||
label: 'Add new file',
|
||||
suffix: 'Create a new file in the current directory or workspace.',
|
||||
icon: 'i-heroicons-document-plus'
|
||||
}, {
|
||||
label: 'Add new folder',
|
||||
suffix: 'Create a new folder in the current directory or workspace.',
|
||||
icon: 'i-heroicons-folder-plus',
|
||||
kbds: ['meta', 'F']
|
||||
}, {
|
||||
label: 'Add hashtag',
|
||||
suffix: 'Add a hashtag to the current item.',
|
||||
icon: 'i-heroicons-hashtag',
|
||||
kbds: ['meta', 'H']
|
||||
}, {
|
||||
label: 'Add label',
|
||||
suffix: 'Add a label to the current item.',
|
||||
icon: 'i-heroicons-tag',
|
||||
kbds: ['meta', 'L']
|
||||
}]
|
||||
}]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCommandPalette :groups="groups" />
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/ContainerExample.vue
Normal file
5
src/devtools/runtime/examples/ContainerExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<UContainer>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-60 aspect-video w-72" />
|
||||
</UContainer>
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/ContextMenuExample.vue
Normal file
5
src/devtools/runtime/examples/ContextMenuExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<UContextMenu>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-60 w-72" />
|
||||
</UContextMenu>
|
||||
</template>
|
||||
8
src/devtools/runtime/examples/DrawerExample.vue
Normal file
8
src/devtools/runtime/examples/DrawerExample.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<UDrawer>
|
||||
<UButton label="Open Drawer" />
|
||||
<template #body>
|
||||
<div class="size-96" />
|
||||
</template>
|
||||
</UDrawer>
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/DropdownMenuExample.vue
Normal file
5
src/devtools/runtime/examples/DropdownMenuExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<UDropdownMenu>
|
||||
<UButton label="Open Dropdown" />
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
30
src/devtools/runtime/examples/FormExample.vue
Normal file
30
src/devtools/runtime/examples/FormExample.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({ email: undefined, password: undefined })
|
||||
|
||||
function validate(data: Partial<typeof state>) {
|
||||
const errors: Array<{ name: string, message: string }> = []
|
||||
if (!data.email) errors.push({ name: 'email', message: 'Required' })
|
||||
if (!data.password) errors.push({ name: 'password', message: 'Required' })
|
||||
return errors
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm
|
||||
:validate="validate"
|
||||
:state="state"
|
||||
class="space-y-4"
|
||||
>
|
||||
<UFormField name="email" label="Email">
|
||||
<UInput v-model="state.email" />
|
||||
</UFormField>
|
||||
<UFormField name="password" label="Password">
|
||||
<UInput v-model="state.password" />
|
||||
</UFormField>
|
||||
<UButton type="submit">
|
||||
Submit
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/FormFieldExample.vue
Normal file
5
src/devtools/runtime/examples/FormFieldExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<UFormField>
|
||||
<UInput />
|
||||
</UFormField>
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/LinkExample.vue
Normal file
5
src/devtools/runtime/examples/LinkExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<ULink>
|
||||
Link
|
||||
</ULink>
|
||||
</template>
|
||||
8
src/devtools/runtime/examples/ModalExample.vue
Normal file
8
src/devtools/runtime/examples/ModalExample.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<UModal>
|
||||
<UButton label="Open Modal" />
|
||||
<template #content>
|
||||
<div class="h-72" />
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
8
src/devtools/runtime/examples/PopoverExample.vue
Normal file
8
src/devtools/runtime/examples/PopoverExample.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<UPopover>
|
||||
<UButton label="Open Collapse" />
|
||||
<template #content>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 h-24 w-60" />
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
3
src/devtools/runtime/examples/SkeletonExample.vue
Normal file
3
src/devtools/runtime/examples/SkeletonExample.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<USkeleton class="h-32 w-96" />
|
||||
</template>
|
||||
8
src/devtools/runtime/examples/SlideoverExample.vue
Normal file
8
src/devtools/runtime/examples/SlideoverExample.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<USlideover>
|
||||
<UButton label="Open Slideover" />
|
||||
<template #body>
|
||||
<div class="size-96" />
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
11
src/devtools/runtime/examples/ToasterExample.vue
Normal file
11
src/devtools/runtime/examples/ToasterExample.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup>
|
||||
import { useToast } from '#imports'
|
||||
|
||||
const toast = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UToaster>
|
||||
<UButton label="Open toast" @click="toast.add({ title: 'Heads up!' })" />
|
||||
</UToaster>
|
||||
</template>
|
||||
5
src/devtools/runtime/examples/TooltipExample.vue
Normal file
5
src/devtools/runtime/examples/TooltipExample.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<UTooltip>
|
||||
<div class="bg-[var(--ui-bg-accented)]/40 size-20" />
|
||||
</UTooltip>
|
||||
</template>
|
||||
Reference in New Issue
Block a user