Merge remote-tracking branch 'origin/v3' into feat/init-blog

This commit is contained in:
HugoRCD
2025-07-01 10:34:52 +02:00
84 changed files with 4218 additions and 3474 deletions

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
const route = useRoute()
const toast = useToast()
const { copy, copied } = useClipboard()
const site = useSiteConfig()
const mdPath = computed(() => `${site.url}/raw${route.path}.md`)
const items = [
{
label: 'Copy Markdown link',
icon: 'i-lucide-link',
onSelect() {
copy(mdPath.value)
toast.add({
title: 'Copied to clipboard',
icon: 'i-lucide-check-circle'
})
}
},
{
label: 'View as Markdown',
icon: 'i-simple-icons:markdown',
target: '_blank',
to: `/raw${route.path}.md`
},
{
label: 'Open in ChatGPT',
icon: 'i-simple-icons:openai',
target: '_blank',
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
},
{
label: 'Open in Claude',
icon: 'i-simple-icons:anthropic',
target: '_blank',
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
}
]
async function copyPage() {
copy(await $fetch<string>(`/raw${route.path}.md`))
}
</script>
<template>
<UButtonGroup>
<UButton
label="Copy page"
:icon="copied ? 'i-lucide-copy-check' : 'i-lucide-copy'"
color="neutral"
variant="outline"
:ui="{
leadingIcon: [copied ? 'text-primary' : 'text-neutral', 'size-3.5']
}"
@click="copyPage"
/>
<UDropdownMenu
:items="items"
:content="{
align: 'end',
side: 'bottom',
sideOffset: 8
}"
:ui="{
content: 'w-48'
}"
>
<UButton
icon="i-lucide-chevron-down"
size="sm"
color="neutral"
variant="outline"
/>
</UDropdownMenu>
</UButtonGroup>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
const open = ref(false)
const anchor = ref({ x: 0, y: 0 })
const reference = computed(() => ({
getBoundingClientRect: () =>
({
width: 0,
height: 0,
left: anchor.value.x,
right: anchor.value.x,
top: anchor.value.y,
bottom: anchor.value.y,
...anchor.value
} as DOMRect)
}))
</script>
<template>
<UTooltip
:open="open"
:reference="reference"
:content="{ side: 'top', sideOffset: 16, updatePositionStrategy: 'always' }"
>
<div
class="flex items-center justify-center rounded-md border border-dashed border-accented text-sm aspect-video w-72"
@pointerenter="open = true"
@pointerleave="open = false"
@pointermove="(ev) => {
anchor.x = ev.clientX
anchor.y = ev.clientY
}"
>
Hover me
</div>
<template #content>
{{ anchor.x.toFixed(0) }} - {{ anchor.y.toFixed(0) }}
</template>
</UTooltip>
</template>

View File

@@ -2,7 +2,8 @@
import { kebabCase } from 'scule'
import type { ContentNavigationItem } from '@nuxt/content'
import type { PageLink } from '@nuxt/ui-pro'
import { findPageBreadcrumb, mapContentNavigation } from '@nuxt/ui-pro/utils/content'
import { mapContentNavigation } from '@nuxt/ui-pro/utils/content'
import { findPageBreadcrumb } from '@nuxt/content/utils'
const route = useRoute()
const { framework, module } = useSharedData()
@@ -37,7 +38,7 @@ const { data: surround } = await useAsyncData(`${kebabCase(route.path)}-surround
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const breadcrumb = computed(() => mapContentNavigation(findPageBreadcrumb(navigation?.value, page.value)).map(({ icon, ...link }) => link))
const breadcrumb = computed(() => mapContentNavigation(findPageBreadcrumb(navigation?.value, page.value?.path, { indexAsChild: true })).map(({ icon, ...link }) => link))
if (!import.meta.prerender) {
// Redirect to the correct framework version if the page is not the current framework
@@ -141,7 +142,7 @@ const communityLinks = computed(() => [{
<MDC v-if="page.description" :value="page.description" unwrap="p" :cache-key="`${kebabCase(route.path)}-description`" />
</template>
<template v-if="page.links?.length" #links>
<template #links>
<UButton
v-for="link in page.links"
:key="link.label"
@@ -154,6 +155,7 @@ const communityLinks = computed(() => [{
<UAvatar v-bind="link.avatar" size="2xs" :alt="`${link.label} avatar`" />
</template>
</UButton>
<PageHeaderLinks />
</template>
</UPageHeader>

View File

@@ -125,7 +125,7 @@ Look at the `code` parameter, there you need to pass the iso code of the languag
::
### Extend locale :badge{label="Soon" class="align-text-top"}
### Extend locale :badge{label="New" class="align-text-top"}
You can customize an existing locale by overriding its `messages` or `code` using the `extendLocale` composable:

View File

@@ -127,7 +127,7 @@ Look at the `code` parameter, there you need to pass the iso code of the languag
::
### Extend locale :badge{label="Soon" class="align-text-top"}
### Extend locale :badge{label="New" class="align-text-top"}
You can customize an existing locale by overriding its `messages` or `code` using the `extendLocale` composable:

View File

@@ -9,7 +9,6 @@ links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/CheckboxGroup.vue
navigation.badge: New
---

View File

@@ -156,7 +156,7 @@ props:
---
::
### Variant :badge{label="New" class="align-text-top"}
### Variant
Use the `variant` prop to change the variant of the Checkbox.
@@ -190,7 +190,7 @@ props:
---
::
### Indicator :badge{label="New" class="align-text-top"}
### Indicator
Use the `indicator` prop to change the position or hide the indicator. Defaults to `start`.

View File

@@ -52,8 +52,8 @@ Each group contains an `items` array of objects that define the commands. Each i
- `loading?: boolean`{lang="ts-type"}
- `disabled?: boolean`{lang="ts-type"}
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
- `placeholder?: string`{lang="ts-type"} :badge{label="Soon"}
- `children?: CommandPaletteItem[]`{lang="ts-type"} :badge{label="Soon"}
- `placeholder?: string`{lang="ts-type"}
- `children?: CommandPaletteItem[]`{lang="ts-type"}
- `onSelect?(e?: Event): void`{lang="ts-type"}
- `class?: any`{lang="ts-type"}
- `ui?: { item?: ClassNameValue, itemLeadingIcon?: ClassNameValue, itemLeadingAvatarSize?: ClassNameValue, itemLeadingAvatar?: ClassNameValue, itemLeadingChipSize?: ClassNameValue, itemLeadingChip?: ClassNameValue, itemLabel?: ClassNameValue, itemLabelPrefix?: ClassNameValue, itemLabelBase?: ClassNameValue, itemLabelSuffix?: ClassNameValue, itemTrailing?: ClassNameValue, itemTrailingKbds?: ClassNameValue, itemTrailingKbdsSize?: ClassNameValue, itemTrailingHighlightedIcon?: ClassNameValue, itemTrailingIcon?: ClassNameValue }`{lang="ts-type"}
@@ -327,7 +327,7 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.ch
:::
::
### Trailing Icon :badge{label="Soon" class="align-text-top"}
### Trailing Icon :badge{label="New" class="align-text-top"}
Use the `trailing-icon` prop to customize the trailing [Icon](/components/icon) when an item has children. Defaults to `i-lucide-chevron-right`.
@@ -565,7 +565,7 @@ You can customize this icon globally in your `vite.config.ts` under `ui.icons.cl
:::
::
### Back :badge{label="Soon" class="align-text-top"}
### Back :badge{label="New" class="align-text-top"}
Use the `back` prop to customize or hide the back button (with `false` value) displayed when navigating into a submenu.
@@ -604,7 +604,7 @@ props:
---
::
### Back Icon :badge{label="Soon" class="align-text-top"}
### Back Icon :badge{label="New" class="align-text-top"}
Use the `back-icon` prop to customize the back button [Icon](/components/icon). Defaults to `i-lucide-arrow-left`.
@@ -717,7 +717,7 @@ props:
This example uses the `@update:model-value` event to reset the search term when an item is selected.
::
### With children in items :badge{label="Soon" class="align-text-top"}
### With children in items :badge{label="New" class="align-text-top"}
You can create hierarchical menus by using the `children` property in items. When an item has children, it will automatically display a chevron icon and enable navigation into a submenu.

View File

@@ -9,7 +9,7 @@ links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/InputTags.vue
navigation.badge: Soon
navigation.badge: New
---
## Usage

View File

@@ -889,7 +889,7 @@ You can inspect the DOM to see each item's content being rendered.
## Examples
### With tooltip in items :badge{label="New" class="align-text-top"}
### With tooltip in items
When orientation is `vertical` and the menu is `collapsed`, you can set the `tooltip` prop to `true` to display a [Tooltip](/components/tooltip) around items with their label but you can also use the `tooltip` property on each item to override the default tooltip.
@@ -994,7 +994,7 @@ props:
---
::
### With popover in items :badge{label="New" class="align-text-top"}
### With popover in items
When orientation is `vertical` and the menu is `collapsed`, you can set the `popover` prop to `true` to display a [Popover](/components/popover) around items with their children but you can also use the `popover` property on each item to override the default popover.

View File

@@ -202,7 +202,7 @@ name: 'popover-command-palette-example'
---
::
### With anchor slot :badge{label="New" class="align-text-top"}
### With anchor slot
You can use the `#anchor` slot to position the Popover against a custom element.

View File

@@ -159,7 +159,7 @@ props:
---
::
### Variant :badge{label="New" class="align-text-top"}
### Variant
Use the `variant` prop to change the variant of the RadioGroup.
@@ -240,7 +240,7 @@ props:
---
::
### Indicator :badge{label="New" class="align-text-top"}
### Indicator
Use the `indicator` prop to change the position or hide the indicator. Defaults to `start`.

View File

@@ -136,7 +136,7 @@ props:
---
::
### Tooltip :badge{label="New" class="align-text-top"}
### Tooltip
Use the `tooltip` prop to display a [Tooltip](/components/tooltip) around the Slider thumbs with the current value. You can set it to `true` for default behavior or pass an object to customize it with any property from the [Tooltip](/components/tooltip#props) component.

View File

@@ -124,7 +124,7 @@ props:
---
::
### Icon :badge{label="New" class="align-text-top"}
### Icon
Use the `icon` prop to show an [Icon](/components/icon) inside the Textarea.
@@ -157,7 +157,7 @@ props:
---
::
### Avatar :badge{label="New" class="align-text-top"}
### Avatar
Use the `avatar` prop to show an [Avatar](/components/avatar) inside the Textarea.
@@ -176,7 +176,7 @@ props:
---
::
### Loading :badge{label="New" class="align-text-top"}
### Loading
Use the `loading` prop to show a loading icon on the Textarea.
@@ -192,7 +192,7 @@ props:
---
::
### Loading Icon :badge{label="New" class="align-text-top"}
### Loading Icon
Use the `loading-icon` prop to customize the loading icon. Defaults to `i-lucide-loader-circle`.

View File

@@ -6,7 +6,7 @@ links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/Timeline.vue
navigation.badge: Soon
navigation.badge: New
---
## Usage

View File

@@ -186,6 +186,20 @@ name: 'tooltip-open-example'
In this example, leveraging [`defineShortcuts`](/composables/define-shortcuts), you can toggle the Tooltip by pressing :kbd{value="O"}.
::
### With following cursor :badge{label="Soon" class="align-text-top"}
You can make the Tooltip follow the cursor when hovering over an element using the [`reference`](https://reka-ui.com/docs/components/tooltip#trigger) prop:
::component-example
---
name: 'tooltip-cursor-example'
---
::
::note
This example is based on Reka UI's [Tooltip Cursor](https://reka-ui.com/examples/tooltip-cursor) example.
::
## API
### Props

View File

@@ -11,13 +11,13 @@
"dependencies": {
"@ai-sdk/vue": "^1.2.12",
"@iconify-json/logos": "^1.2.4",
"@iconify-json/lucide": "^1.2.51",
"@iconify-json/simple-icons": "^1.2.39",
"@iconify-json/lucide": "^1.2.54",
"@iconify-json/simple-icons": "^1.2.41",
"@iconify-json/vscode-icons": "^1.2.23",
"@nuxt/content": "^3.6.1",
"@nuxt/image": "^1.10.0",
"@nuxt/ui": "workspace:*",
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@beebbd4",
"@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@3d48704",
"@nuxthub/core": "^0.9.0",
"@nuxtjs/plausible": "^1.2.0",
"@octokit/rest": "^22.0.0",
@@ -25,16 +25,16 @@
"@vueuse/integrations": "^13.4.0",
"@vueuse/nuxt": "^13.4.0",
"ai": "^4.3.16",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^12.2.0",
"capture-website": "^4.2.0",
"joi": "^17.13.3",
"maska": "^3.1.1",
"motion-v": "^1.3.0",
"motion-v": "^1.3.1",
"nuxt": "^3.17.5",
"nuxt-component-meta": "^0.11.0",
"nuxt-component-meta": "^0.12.0",
"nuxt-llms": "^0.1.3",
"nuxt-og-image": "^5.1.7",
"prettier": "^3.6.0",
"nuxt-og-image": "^5.1.8",
"prettier": "^3.6.2",
"shiki-transformer-color-highlight": "^1.0.0",
"sortablejs": "^1.15.6",
"superstruct": "^2.0.2",
@@ -45,6 +45,6 @@
"zod": "^3.25.67"
},
"devDependencies": {
"wrangler": "^4.20.5"
"wrangler": "^4.22.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -1,412 +1,8 @@
import json5 from 'json5'
import { camelCase, kebabCase } from 'scule'
import { visit } from '@nuxt/content/runtime'
import type { H3Event } from 'h3'
import type { PageCollectionItemBase } from '@nuxt/content'
import * as theme from '../../.nuxt/ui'
import * as themePro from '../../.nuxt/ui-pro'
import meta from '#nuxt-component-meta'
// @ts-expect-error - no types available
import components from '#component-example/nitro'
type ComponentAttributes = {
':pro'?: string
':prose'?: string
':props'?: string
':external'?: string
':externalTypes'?: string
':ignore'?: string
':hide'?: string
':slots'?: string
}
type ThemeConfig = {
pro: boolean
prose: boolean
componentName: string
}
type CodeConfig = {
pro: boolean
props: Record<string, unknown>
external: string[]
externalTypes: string[]
ignore: string[]
hide: string[]
componentName: string
slots?: Record<string, string>
}
type Document = {
title: string
body: any
}
const parseBoolean = (value?: string): boolean => value === 'true'
function getComponentMeta(componentName: string) {
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
const strategies = [
`U${pascalCaseName}`,
`Prose${pascalCaseName}`,
pascalCaseName
]
let componentMeta: any
let finalMetaComponentName: string = pascalCaseName
for (const nameToTry of strategies) {
finalMetaComponentName = nameToTry
const metaAttempt = (meta as Record<string, any>)[nameToTry]?.meta
if (metaAttempt) {
componentMeta = metaAttempt
break
}
}
if (!componentMeta) {
console.warn(`[getComponentMeta] Metadata not found for ${pascalCaseName} using strategies: U, Prose, or no prefix. Last tried: ${finalMetaComponentName}`)
}
return {
pascalCaseName,
metaComponentName: finalMetaComponentName,
componentMeta
}
}
function replaceNodeWithPre(node: any[], language: string, code: string, filename?: string) {
node[0] = 'pre'
node[1] = { language, code }
if (filename) node[1].filename = filename
}
function visitAndReplace(doc: Document, type: string, handler: (node: any[]) => void) {
visit(doc.body, (node) => {
if (Array.isArray(node) && node[0] === type) {
handler(node)
}
return true
}, node => node)
}
function generateTSInterface(
name: string,
items: any[],
itemHandler: (item: any) => string,
description: string
) {
let code = `/**\n * ${description}\n */\ninterface ${name} {\n`
for (const item of items) {
code += itemHandler(item)
}
code += `}`
return code
}
function propItemHandler(propValue: any): string {
if (!propValue?.name) return ''
const propName = propValue.name
const propType = propValue.type
? Array.isArray(propValue.type)
? propValue.type.map((t: any) => t.name || t).join(' | ')
: propValue.type.name || propValue.type
: 'any'
const isRequired = propValue.required || false
const hasDescription = propValue.description && propValue.description.trim().length > 0
const hasDefault = propValue.default !== undefined
let result = ''
if (hasDescription || hasDefault) {
result += ` /**\n`
if (hasDescription) {
const descLines = propValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
}
if (hasDefault) {
let defaultValue = propValue.default
if (typeof defaultValue === 'string') {
defaultValue = `"${defaultValue.replace(/"/g, '\\"')}"`
} else {
defaultValue = JSON.stringify(defaultValue)
}
result += ` * @default ${defaultValue}\n`
}
result += ` */\n`
}
result += ` ${propName}${isRequired ? '' : '?'}: ${propType};\n`
return result
}
function slotItemHandler(slotValue: any): string {
if (!slotValue?.name) return ''
const slotName = slotValue.name
const hasDescription = slotValue.description && slotValue.description.trim().length > 0
let result = ''
if (hasDescription) {
result += ` /**\n`
const descLines = slotValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
if (slotValue.bindings && Object.keys(slotValue.bindings).length > 0) {
let bindingsType = '{\n'
Object.entries(slotValue.bindings).forEach(([bindingName, bindingValue]: [string, any]) => {
const bindingType = bindingValue.type || 'any'
bindingsType += ` ${bindingName}: ${bindingType};\n`
})
bindingsType += ' }'
result += ` ${slotName}(bindings: ${bindingsType}): any;\n`
} else {
result += ` ${slotName}(): any;\n`
}
return result
}
function emitItemHandler(event: any): string {
if (!event?.name) return ''
let payloadType = 'void'
if (event.type) {
payloadType = Array.isArray(event.type)
? event.type.map((t: any) => t.name || t).join(' | ')
: event.type.name || event.type
}
let result = ''
if (event.description && event.description.trim().length > 0) {
result += ` /**\n`
event.description.split(/\r?\n/).forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
result += ` ${event.name}: (payload: ${payloadType}) => void;\n`
return result
}
const generateThemeConfig = ({ pro, prose, componentName }: ThemeConfig) => {
const computedTheme = pro ? (prose ? themePro.prose : themePro) : theme
const componentTheme = computedTheme[componentName as keyof typeof computedTheme]
return {
[pro ? 'uiPro' : 'ui']: prose
? { prose: { [componentName]: componentTheme } }
: { [componentName]: componentTheme }
}
}
const generateComponentCode = ({
pro,
props,
external,
externalTypes,
hide,
componentName,
slots
}: CodeConfig) => {
const filteredProps = Object.fromEntries(
Object.entries(props).filter(([key]) => !hide.includes(key))
)
const imports = pro
? ''
: external
.filter((_, index) => externalTypes[index] && externalTypes[index] !== 'undefined')
.map((ext, index) => {
const type = externalTypes[index]?.replace(/[[\]]/g, '')
return `import type { ${type} } from '@nuxt/${pro ? 'ui-pro' : 'ui'}'`
})
.join('\n')
let itemsCode = ''
if (props.items) {
itemsCode = pro
? `const items = ref(${json5.stringify(props.items, null, 2)})`
: `const items = ref<${externalTypes[0]}>(${json5.stringify(props.items, null, 2)})`
delete filteredProps.items
}
let calendarValueCode = ''
if (componentName === 'calendar' && props.modelValue && Array.isArray(props.modelValue)) {
calendarValueCode = `const value = ref(new CalendarDate(${props.modelValue.join(', ')}))`
}
const propsString = Object.entries(filteredProps)
.map(([key, value]) => {
const formattedKey = kebabCase(key)
if (typeof value === 'string') {
return `${formattedKey}="${value}"`
} else if (typeof value === 'number') {
return `:${formattedKey}="${value}"`
} else if (typeof value === 'boolean') {
return value ? formattedKey : `:${formattedKey}="false"`
}
return ''
})
.filter(Boolean)
.join(' ')
const itemsProp = props.items ? ':items="items"' : ''
const vModelProp = componentName === 'calendar' && props.modelValue ? 'v-model="value"' : ''
const allProps = [propsString, itemsProp, vModelProp].filter(Boolean).join(' ')
const formattedProps = allProps ? ` ${allProps}` : ''
let scriptSetup = ''
if (imports || itemsCode || calendarValueCode) {
scriptSetup = '<script setup lang="ts">'
if (imports) scriptSetup += `\n${imports}`
if (imports && (itemsCode || calendarValueCode)) scriptSetup += '\n'
if (calendarValueCode) scriptSetup += `\n${calendarValueCode}`
if (itemsCode) scriptSetup += `\n${itemsCode}`
scriptSetup += '\n</script>\n\n'
}
let componentContent = ''
let slotContent = ''
if (slots && Object.keys(slots).length > 0) {
const defaultSlot = slots.default?.trim()
if (defaultSlot) {
const indentedContent = defaultSlot
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
componentContent = `\n${indentedContent}\n `
}
Object.entries(slots).forEach(([slotName, content]) => {
if (slotName !== 'default' && content?.trim()) {
const indentedSlotContent = content.trim()
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
slotContent += `\n <template #${slotName}>\n${indentedSlotContent}\n </template>`
}
})
}
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
let componentTemplate = ''
if (componentContent || slotContent) {
componentTemplate = `<U${pascalCaseName}${formattedProps}>${componentContent}${slotContent}</U${pascalCaseName}>` // Removed space before closing tag
} else {
componentTemplate = `<U${pascalCaseName}${formattedProps} />`
}
return `${scriptSetup}<template>
${componentTemplate}
</template>`
}
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('content:llms:generate:document', async (_: H3Event, doc: PageCollectionItemBase) => {
const componentName = camelCase(doc.title)
visitAndReplace(doc, 'component-theme', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.slug
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const pro = parseBoolean(attributes[':pro'])
const prose = parseBoolean(attributes[':prose'])
const appConfig = generateThemeConfig({ pro, prose, componentName: finalComponentName })
replaceNodeWithPre(
node,
'ts',
`export default defineAppConfig(${json5.stringify(appConfig, null, 2)?.replace(/,([ |\t\n]+[}|\])])/g, '$1')})`,
'app.config.ts'
)
})
visitAndReplace(doc, 'component-code', (node) => {
const attributes = node[1] as ComponentAttributes
const pro = parseBoolean(attributes[':pro'])
const props = attributes[':props'] ? json5.parse(attributes[':props']) : {}
const external = attributes[':external'] ? json5.parse(attributes[':external']) : []
const externalTypes = attributes[':externalTypes'] ? json5.parse(attributes[':externalTypes']) : []
const ignore = attributes[':ignore'] ? json5.parse(attributes[':ignore']) : []
const hide = attributes[':hide'] ? json5.parse(attributes[':hide']) : []
const slots = attributes[':slots'] ? json5.parse(attributes[':slots']) : {}
const code = generateComponentCode({
pro,
props,
external,
externalTypes,
ignore,
hide,
componentName,
slots
})
replaceNodeWithPre(node, 'vue', code)
})
visitAndReplace(doc, 'component-props', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.name
const isProse = parseBoolean(attributes[':prose'])
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const { pascalCaseName, componentMeta } = getComponentMeta(finalComponentName)
if (!componentMeta?.props) return
const interfaceName = isProse ? `Prose${pascalCaseName}Props` : `${pascalCaseName}Props`
const interfaceCode = generateTSInterface(
interfaceName,
Object.values(componentMeta.props),
propItemHandler,
`Props for the ${isProse ? 'Prose' : ''}${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-slots', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
if (!componentMeta?.slots) return
const interfaceCode = generateTSInterface(
`${pascalCaseName}Slots`,
Object.values(componentMeta.slots),
slotItemHandler,
`Slots for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-emits', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
const hasEvents = componentMeta?.events && Object.keys(componentMeta.events).length > 0
if (hasEvents) {
const interfaceCode = generateTSInterface(
`${pascalCaseName}Emits`,
Object.values(componentMeta.events),
emitItemHandler,
`Emitted events for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
} else {
node[0] = 'p'
node[1] = {}
node[2] = 'No events available for this component.'
}
})
visitAndReplace(doc, 'component-example', (node) => {
const camelName = camelCase(node[1]['name'])
const name = camelName.charAt(0).toUpperCase() + camelName.slice(1)
const code = components[name].code
replaceNodeWithPre(node, 'vue', code, `${name}.vue`)
})
transformMDC(doc as any)
})
})

View File

@@ -0,0 +1,30 @@
import { stringify } from 'minimark/stringify'
import { withLeadingSlash } from 'ufo'
export default eventHandler(async (event) => {
const slug = getRouterParams(event)['slug.md']
if (!slug?.endsWith('.md')) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const path = withLeadingSlash(slug.replace('.md', ''))
// @ts-expect-error TODO: fix this
const page = await queryCollection(event, 'content').path(path).first()
if (!page) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
// Add title and description to the top of the page if missing
if (page.body.value[0]?.[0] !== 'h1') {
page.body.value.unshift(['blockquote', {}, page.description])
page.body.value.unshift(['h1', {}, page.title])
}
const transformedPage = transformMDC({
title: page.title,
body: page.body
})
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
return stringify({ ...transformedPage.body, type: 'minimark' }, { format: 'markdown/html' })
})

View File

@@ -0,0 +1,410 @@
import json5 from 'json5'
import { camelCase, kebabCase } from 'scule'
import { visit } from '@nuxt/content/runtime'
import * as theme from '../../.nuxt/ui'
import * as themePro from '../../.nuxt/ui-pro'
import meta from '#nuxt-component-meta'
// @ts-expect-error - no types available
import components from '#component-example/nitro'
type ComponentAttributes = {
':pro'?: string
':prose'?: string
':props'?: string
':external'?: string
':externalTypes'?: string
':ignore'?: string
':hide'?: string
':slots'?: string
}
type ThemeConfig = {
pro: boolean
prose: boolean
componentName: string
}
type CodeConfig = {
pro: boolean
props: Record<string, unknown>
external: string[]
externalTypes: string[]
ignore: string[]
hide: string[]
componentName: string
slots?: Record<string, string>
}
type Document = {
title: string
body: any
}
const parseBoolean = (value?: string): boolean => value === 'true'
function getComponentMeta(componentName: string) {
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
const strategies = [
`U${pascalCaseName}`,
`Prose${pascalCaseName}`,
pascalCaseName
]
let componentMeta: any
let finalMetaComponentName: string = pascalCaseName
for (const nameToTry of strategies) {
finalMetaComponentName = nameToTry
const metaAttempt = (meta as Record<string, any>)[nameToTry]?.meta
if (metaAttempt) {
componentMeta = metaAttempt
break
}
}
if (!componentMeta) {
console.warn(`[getComponentMeta] Metadata not found for ${pascalCaseName} using strategies: U, Prose, or no prefix. Last tried: ${finalMetaComponentName}`)
}
return {
pascalCaseName,
metaComponentName: finalMetaComponentName,
componentMeta
}
}
function replaceNodeWithPre(node: any[], language: string, code: string, filename?: string) {
node[0] = 'pre'
node[1] = { language, code }
if (filename) node[1].filename = filename
}
function visitAndReplace(doc: Document, type: string, handler: (node: any[]) => void) {
visit(doc.body, (node) => {
if (Array.isArray(node) && node[0] === type) {
handler(node)
}
return true
}, node => node)
}
function generateTSInterface(
name: string,
items: any[],
itemHandler: (item: any) => string,
description: string
) {
let code = `/**\n * ${description}\n */\ninterface ${name} {\n`
for (const item of items) {
code += itemHandler(item)
}
code += `}`
return code
}
function propItemHandler(propValue: any): string {
if (!propValue?.name) return ''
const propName = propValue.name
const propType = propValue.type
? Array.isArray(propValue.type)
? propValue.type.map((t: any) => t.name || t).join(' | ')
: propValue.type.name || propValue.type
: 'any'
const isRequired = propValue.required || false
const hasDescription = propValue.description && propValue.description.trim().length > 0
const hasDefault = propValue.default !== undefined
let result = ''
if (hasDescription || hasDefault) {
result += ` /**\n`
if (hasDescription) {
const descLines = propValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
}
if (hasDefault) {
let defaultValue = propValue.default
if (typeof defaultValue === 'string') {
defaultValue = `"${defaultValue.replace(/"/g, '\\"')}"`
} else {
defaultValue = JSON.stringify(defaultValue)
}
result += ` * @default ${defaultValue}\n`
}
result += ` */\n`
}
result += ` ${propName}${isRequired ? '' : '?'}: ${propType};\n`
return result
}
function slotItemHandler(slotValue: any): string {
if (!slotValue?.name) return ''
const slotName = slotValue.name
const hasDescription = slotValue.description && slotValue.description.trim().length > 0
let result = ''
if (hasDescription) {
result += ` /**\n`
const descLines = slotValue.description.split(/\r?\n/)
descLines.forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
if (slotValue.bindings && Object.keys(slotValue.bindings).length > 0) {
let bindingsType = '{\n'
Object.entries(slotValue.bindings).forEach(([bindingName, bindingValue]: [string, any]) => {
const bindingType = bindingValue.type || 'any'
bindingsType += ` ${bindingName}: ${bindingType};\n`
})
bindingsType += ' }'
result += ` ${slotName}(bindings: ${bindingsType}): any;\n`
} else {
result += ` ${slotName}(): any;\n`
}
return result
}
function emitItemHandler(event: any): string {
if (!event?.name) return ''
let payloadType = 'void'
if (event.type) {
payloadType = Array.isArray(event.type)
? event.type.map((t: any) => t.name || t).join(' | ')
: event.type.name || event.type
}
let result = ''
if (event.description && event.description.trim().length > 0) {
result += ` /**\n`
event.description.split(/\r?\n/).forEach((line: string) => {
result += ` * ${line}\n`
})
result += ` */\n`
}
result += ` ${event.name}: (payload: ${payloadType}) => void;\n`
return result
}
const generateThemeConfig = ({ pro, prose, componentName }: ThemeConfig) => {
const computedTheme = pro ? (prose ? themePro.prose : themePro) : theme
const componentTheme = computedTheme[componentName as keyof typeof computedTheme]
return {
[pro ? 'uiPro' : 'ui']: prose
? { prose: { [componentName]: componentTheme } }
: { [componentName]: componentTheme }
}
}
const generateComponentCode = ({
pro,
props,
external,
externalTypes,
hide,
componentName,
slots
}: CodeConfig) => {
const filteredProps = Object.fromEntries(
Object.entries(props).filter(([key]) => !hide.includes(key))
)
const imports = pro
? ''
: external
.filter((_, index) => externalTypes[index] && externalTypes[index] !== 'undefined')
.map((ext, index) => {
const type = externalTypes[index]?.replace(/[[\]]/g, '')
return `import type { ${type} } from '@nuxt/${pro ? 'ui-pro' : 'ui'}'`
})
.join('\n')
let itemsCode = ''
if (props.items) {
itemsCode = pro
? `const items = ref(${json5.stringify(props.items, null, 2)})`
: `const items = ref<${externalTypes[0]}>(${json5.stringify(props.items, null, 2)})`
delete filteredProps.items
}
let calendarValueCode = ''
if (componentName === 'calendar' && props.modelValue && Array.isArray(props.modelValue)) {
calendarValueCode = `const value = ref(new CalendarDate(${props.modelValue.join(', ')}))`
}
const propsString = Object.entries(filteredProps)
.map(([key, value]) => {
const formattedKey = kebabCase(key)
if (typeof value === 'string') {
return `${formattedKey}="${value}"`
} else if (typeof value === 'number') {
return `:${formattedKey}="${value}"`
} else if (typeof value === 'boolean') {
return value ? formattedKey : `:${formattedKey}="false"`
}
return ''
})
.filter(Boolean)
.join(' ')
const itemsProp = props.items ? ':items="items"' : ''
const vModelProp = componentName === 'calendar' && props.modelValue ? 'v-model="value"' : ''
const allProps = [propsString, itemsProp, vModelProp].filter(Boolean).join(' ')
const formattedProps = allProps ? ` ${allProps}` : ''
let scriptSetup = ''
if (imports || itemsCode || calendarValueCode) {
scriptSetup = '<script setup lang="ts">'
if (imports) scriptSetup += `\n${imports}`
if (imports && (itemsCode || calendarValueCode)) scriptSetup += '\n'
if (calendarValueCode) scriptSetup += `\n${calendarValueCode}`
if (itemsCode) scriptSetup += `\n${itemsCode}`
scriptSetup += '\n</script>\n\n'
}
let componentContent = ''
let slotContent = ''
if (slots && Object.keys(slots).length > 0) {
const defaultSlot = slots.default?.trim()
if (defaultSlot) {
const indentedContent = defaultSlot
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
componentContent = `\n${indentedContent}\n `
}
Object.entries(slots).forEach(([slotName, content]) => {
if (slotName !== 'default' && content?.trim()) {
const indentedSlotContent = content.trim()
.split('\n')
.map(line => line.trim() ? ` ${line}` : line)
.join('\n')
slotContent += `\n <template #${slotName}>\n${indentedSlotContent}\n </template>`
}
})
}
const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1)
let componentTemplate = ''
if (componentContent || slotContent) {
componentTemplate = `<U${pascalCaseName}${formattedProps}>${componentContent}${slotContent}</U${pascalCaseName}>` // Removed space before closing tag
} else {
componentTemplate = `<U${pascalCaseName}${formattedProps} />`
}
return `${scriptSetup}<template>
${componentTemplate}
</template>`
}
export function transformMDC(doc: Document): Document {
const componentName = camelCase(doc.title)
visitAndReplace(doc, 'component-theme', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.slug
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const pro = parseBoolean(attributes[':pro'])
const prose = parseBoolean(attributes[':prose'])
const appConfig = generateThemeConfig({ pro, prose, componentName: finalComponentName })
replaceNodeWithPre(
node,
'ts',
`export default defineAppConfig(${json5.stringify(appConfig, null, 2)?.replace(/,([ |\t\n]+[}|\])])/g, '$1')})`,
'app.config.ts'
)
})
visitAndReplace(doc, 'component-code', (node) => {
const attributes = node[1] as ComponentAttributes
const pro = parseBoolean(attributes[':pro'])
const props = attributes[':props'] ? json5.parse(attributes[':props']) : {}
const external = attributes[':external'] ? json5.parse(attributes[':external']) : []
const externalTypes = attributes[':externalTypes'] ? json5.parse(attributes[':externalTypes']) : []
const ignore = attributes[':ignore'] ? json5.parse(attributes[':ignore']) : []
const hide = attributes[':hide'] ? json5.parse(attributes[':hide']) : []
const slots = attributes[':slots'] ? json5.parse(attributes[':slots']) : {}
const code = generateComponentCode({
pro,
props,
external,
externalTypes,
ignore,
hide,
componentName,
slots
})
replaceNodeWithPre(node, 'vue', code)
})
visitAndReplace(doc, 'component-props', (node) => {
const attributes = node[1] as Record<string, string>
const mdcSpecificName = attributes?.name
const isProse = parseBoolean(attributes[':prose'])
const finalComponentName = mdcSpecificName ? camelCase(mdcSpecificName) : componentName
const { pascalCaseName, componentMeta } = getComponentMeta(finalComponentName)
if (!componentMeta?.props) return
const interfaceName = isProse ? `Prose${pascalCaseName}Props` : `${pascalCaseName}Props`
const interfaceCode = generateTSInterface(
interfaceName,
Object.values(componentMeta.props),
propItemHandler,
`Props for the ${isProse ? 'Prose' : ''}${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-slots', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
if (!componentMeta?.slots) return
const interfaceCode = generateTSInterface(
`${pascalCaseName}Slots`,
Object.values(componentMeta.slots),
slotItemHandler,
`Slots for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
})
visitAndReplace(doc, 'component-emits', (node) => {
const { pascalCaseName, componentMeta } = getComponentMeta(componentName)
const hasEvents = componentMeta?.events && Object.keys(componentMeta.events).length > 0
if (hasEvents) {
const interfaceCode = generateTSInterface(
`${pascalCaseName}Emits`,
Object.values(componentMeta.events),
emitItemHandler,
`Emitted events for the ${pascalCaseName} component`
)
replaceNodeWithPre(node, 'ts', interfaceCode)
} else {
node[0] = 'p'
node[1] = {}
node[2] = 'No events available for this component.'
}
})
visitAndReplace(doc, 'component-example', (node) => {
const camelName = camelCase(node[1]['name'])
const name = camelName.charAt(0).toUpperCase() + camelName.slice(1)
const code = components[name].code
replaceNodeWithPre(node, 'vue', code, `${name}.vue`)
})
return doc
}