mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
docs(app): add copy markdown button (#4369)
This commit is contained in:
77
docs/app/components/PageHeaderLinks.vue
Normal file
77
docs/app/components/PageHeaderLinks.vue
Normal 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>
|
||||||
@@ -141,7 +141,7 @@ const communityLinks = computed(() => [{
|
|||||||
<MDC v-if="page.description" :value="page.description" unwrap="p" :cache-key="`${kebabCase(route.path)}-description`" />
|
<MDC v-if="page.description" :value="page.description" unwrap="p" :cache-key="`${kebabCase(route.path)}-description`" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="page.links?.length" #links>
|
<template #links>
|
||||||
<UButton
|
<UButton
|
||||||
v-for="link in page.links"
|
v-for="link in page.links"
|
||||||
:key="link.label"
|
:key="link.label"
|
||||||
@@ -154,6 +154,7 @@ const communityLinks = computed(() => [{
|
|||||||
<UAvatar v-bind="link.avatar" size="2xs" :alt="`${link.label} avatar`" />
|
<UAvatar v-bind="link.avatar" size="2xs" :alt="`${link.label} avatar`" />
|
||||||
</template>
|
</template>
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<PageHeaderLinks />
|
||||||
</template>
|
</template>
|
||||||
</UPageHeader>
|
</UPageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { H3Event } from 'h3'
|
||||||
import type { PageCollectionItemBase } from '@nuxt/content'
|
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) => {
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
nitroApp.hooks.hook('content:llms:generate:document', async (_: H3Event, doc: PageCollectionItemBase) => {
|
nitroApp.hooks.hook('content:llms:generate:document', async (_: H3Event, doc: PageCollectionItemBase) => {
|
||||||
const componentName = camelCase(doc.title)
|
transformMDC(doc as any)
|
||||||
|
|
||||||
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`)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
30
docs/server/routes/raw/[...slug].md.get.ts
Normal file
30
docs/server/routes/raw/[...slug].md.get.ts
Normal 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' })
|
||||||
|
})
|
||||||
410
docs/server/utils/transformMDC.ts
Normal file
410
docs/server/utils/transformMDC.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user