feat(module): devtools integration (#2196)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2024-11-05 22:17:56 +01:00
committed by GitHub
parent 7fc6b387b3
commit 701c75a2a8
100 changed files with 2062 additions and 59 deletions

148
src/devtools/meta.ts Normal file
View 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' }))
}
})
}
}
}

View 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>

View 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>

View File

@@ -0,0 +1,8 @@
<template>
<UButtonGroup>
<UInput placeholder="Search..." />
<UButton color="neutral" variant="outline">
Button
</UButton>
</UButtonGroup>
</template>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<UChip>
<UAvatar src="https://avatars.githubusercontent.com/u/739984?v=4" />
</UChip>
</template>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<UContainer>
<div class="bg-[var(--ui-bg-accented)]/40 h-60 aspect-video w-72" />
</UContainer>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<UContextMenu>
<div class="bg-[var(--ui-bg-accented)]/40 h-60 w-72" />
</UContextMenu>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<UDrawer>
<UButton label="Open Drawer" />
<template #body>
<div class="size-96" />
</template>
</UDrawer>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<UDropdownMenu>
<UButton label="Open Dropdown" />
</UDropdownMenu>
</template>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<UFormField>
<UInput />
</UFormField>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<ULink>
Link
</ULink>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<UModal>
<UButton label="Open Modal" />
<template #content>
<div class="h-72" />
</template>
</UModal>
</template>

View 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>

View File

@@ -0,0 +1,3 @@
<template>
<USkeleton class="h-32 w-96" />
</template>

View File

@@ -0,0 +1,8 @@
<template>
<USlideover>
<UButton label="Open Slideover" />
<template #body>
<div class="size-96" />
</template>
</USlideover>
</template>

View 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>

View File

@@ -0,0 +1,5 @@
<template>
<UTooltip>
<div class="bg-[var(--ui-bg-accented)]/40 size-20" />
</UTooltip>
</template>