diff --git a/docs/package.json b/docs/package.json index 82feadf9..2a967783 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,7 +8,7 @@ "@iconify-json/lucide": "^1.2.36", "@iconify-json/simple-icons": "^1.2.32", "@iconify-json/vscode-icons": "^1.2.19", - "@nuxt/content": "^3.4.0", + "@nuxt/content": "https://pkg.pr.new/@nuxt/content@754e480", "@nuxt/image": "^1.10.0", "@nuxt/ui": "latest", "@nuxt/ui-pro": "https://pkg.pr.new/@nuxt/ui-pro@3cc20d8", diff --git a/docs/server/plugins/llms.ts b/docs/server/plugins/llms.ts new file mode 100644 index 00000000..4b0ee71f --- /dev/null +++ b/docs/server/plugins/llms.ts @@ -0,0 +1,374 @@ +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 + external: string[] + externalTypes: string[] + ignore: string[] + hide: string[] + componentName: string + slots?: Record +} + +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 metaComponentName = `U${pascalCaseName}` + return { + pascalCaseName, + metaComponentName, + componentMeta: (meta as Record)[metaComponentName]?.meta + } +} + +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 = '\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 ` + } + }) + } + + const pascalCaseName = componentName.charAt(0).toUpperCase() + componentName.slice(1) + + let componentTemplate = '' + if (componentContent || slotContent) { + componentTemplate = `${componentContent}${slotContent}` // Removed space before closing tag + } else { + componentTemplate = `` + } + + return `${scriptSetup}` +} + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('content:llms:generate:document' as any, async (doc: Document) => { + const componentName = camelCase(doc.title) + + visitAndReplace(doc, 'component-theme', (node) => { + const attributes = node[1] as ComponentAttributes + const pro = parseBoolean(attributes[':pro']) + const prose = parseBoolean(attributes[':prose']) + const appConfig = generateThemeConfig({ pro, prose, componentName }) + + 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 { pascalCaseName, componentMeta } = getComponentMeta(componentName) + if (!componentMeta?.props) return + + const interfaceCode = generateTSInterface( + `${pascalCaseName}Props`, + Object.values(componentMeta.props), + propItemHandler, + `Props for the ${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`) + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4769cb45..cf1f99e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,8 +237,8 @@ importers: specifier: ^1.2.19 version: 1.2.19 '@nuxt/content': - specifier: ^3.4.0 - version: 3.4.0(magicast@0.3.5)(typescript@5.8.3) + specifier: https://pkg.pr.new/@nuxt/content@754e480 + version: https://pkg.pr.new/@nuxt/content@754e480(magicast@0.3.5)(typescript@5.8.3) '@nuxt/image': specifier: ^1.10.0 version: 1.10.0(db0@0.3.1(better-sqlite3@11.9.1))(ioredis@5.6.0)(magicast@0.3.5) @@ -1229,8 +1229,9 @@ packages: engines: {node: ^16.10.0 || >=18.0.0} hasBin: true - '@nuxt/content@3.4.0': - resolution: {integrity: sha512-2+OtY+apAxyKCNki9K9wRz7DgoD7es5HUAMjOJVL5up652o8UFTwihtK1/+9YEI2jXd6+SFMiKsDMe2jGaqu4g==} + '@nuxt/content@https://pkg.pr.new/@nuxt/content@754e480': + resolution: {tarball: https://pkg.pr.new/@nuxt/content@754e480} + version: 3.4.0 peerDependencies: '@electric-sql/pglite': '*' '@libsql/client': '*' @@ -1376,8 +1377,9 @@ packages: '@nuxtjs/color-mode@3.5.2': resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==} - '@nuxtjs/mdc@0.16.1': - resolution: {integrity: sha512-di9Ox9QY5pO2eIkQPyKFe9O8L3RvIrGbmjI3rJQRj1xGYRFj2S9RvBPCFbvfaqQGOTjOfxHLg8KtQIGj1Iw/lg==} + '@nuxtjs/mdc@https://pkg.pr.new/@nuxtjs/mdc@cd1c4fd': + resolution: {tarball: https://pkg.pr.new/@nuxtjs/mdc@cd1c4fd} + version: 0.16.1 '@nuxtjs/plausible@1.2.0': resolution: {integrity: sha512-pjfps32fFN77BhjqHmq2Jx4XCNso9TcYnB+S4IR2qH/c26WDfYB5mQxN5pOEiWRlMkiKq+Y45mBBFtSOVKClCA==} @@ -7970,10 +7972,10 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/content@3.4.0(magicast@0.3.5)(typescript@5.8.3)': + '@nuxt/content@https://pkg.pr.new/@nuxt/content@754e480(magicast@0.3.5)(typescript@5.8.3)': dependencies: '@nuxt/kit': 3.16.2(magicast@0.3.5) - '@nuxtjs/mdc': 0.16.1(magicast@0.3.5) + '@nuxtjs/mdc': https://pkg.pr.new/@nuxtjs/mdc@cd1c4fd(magicast@0.3.5) '@shikijs/langs': 3.2.1 '@sqlite.org/sqlite-wasm': 3.49.1-build2 '@webcontainer/env': 1.1.1 @@ -8496,9 +8498,11 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/mdc@0.16.1(magicast@0.3.5)': + '@nuxtjs/mdc@https://pkg.pr.new/@nuxtjs/mdc@cd1c4fd(magicast@0.3.5)': dependencies: '@nuxt/kit': 3.16.2(magicast@0.3.5) + '@shikijs/langs': 3.2.1 + '@shikijs/themes': 3.2.1 '@shikijs/transformers': 3.2.1 '@types/hast': 3.0.4 '@types/mdast': 4.0.4