|
1 | | -import { promises as fs } from 'node:fs' |
2 | | -import { join, relative } from 'node:path' |
3 | | -import process from 'node:process' |
4 | 1 | import { eventHandler, getRequestURL } from 'h3' |
| 2 | +import { useStorage } from 'nitropack/runtime' |
5 | 3 |
|
6 | 4 | interface RegistryFile { |
7 | 5 | type: |
@@ -63,252 +61,91 @@ interface ItemResponse { |
63 | 61 | registryDependencies: string[] |
64 | 62 | } |
65 | 63 |
|
66 | | -function toTitle(slug: string) { |
67 | | - return slug |
68 | | - .split('-') |
69 | | - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) |
70 | | - .join(' ') |
71 | | -} |
72 | | - |
73 | | -async function walkVueFiles(dir: string): Promise<string[]> { |
74 | | - const out: string[] = [] |
75 | | - const entries = await fs.readdir(dir, { withFileTypes: true }) |
76 | | - for (const entry of entries) { |
77 | | - const full = join(dir, entry.name) |
78 | | - if (entry.isDirectory()) { |
79 | | - const nested = await walkVueFiles(full) |
80 | | - out.push(...nested) |
81 | | - continue |
82 | | - } |
83 | | - if (entry.isFile() && entry.name.endsWith('.vue')) { |
84 | | - out.push(full) |
85 | | - } |
86 | | - } |
87 | | - return out |
88 | | -} |
| 64 | +// All data is served from Nitro Server Assets generated at build time. |
89 | 65 |
|
90 | | -function extractImports(code: string): string[] { |
91 | | - const imports: string[] = [] |
92 | | - const matches = code.matchAll(/import[^\n]*from\s+['"]([^'"\n]+)['"]/g) |
93 | | - for (const m of matches) { |
94 | | - if (m[1]) |
95 | | - imports.push(m[1]) |
| 66 | +function transformRegistryDependencies(item: ItemResponse, registryUrl: string): ItemResponse { |
| 67 | + if (item.registryDependencies && Array.isArray(item.registryDependencies)) { |
| 68 | + item.registryDependencies = item.registryDependencies.map((dep) => { |
| 69 | + // Handle different types of dependencies |
| 70 | + if (dep.startsWith('/')) { |
| 71 | + // Relative path to JSON endpoint (e.g., "/component.json") |
| 72 | + return new URL(dep, registryUrl).toString() |
| 73 | + } |
| 74 | + if (dep.includes('.json')) { |
| 75 | + // Already formatted JSON dependency |
| 76 | + return dep.startsWith('http') ? dep : new URL(`/${dep}`, registryUrl).toString() |
| 77 | + } |
| 78 | + if (dep.match(/^[a-z-]+$/)) { |
| 79 | + // Simple component name (shadcn-vue style) |
| 80 | + return dep |
| 81 | + } |
| 82 | + // Fallback: assume it's a relative path |
| 83 | + return new URL(`/${dep}.json`, registryUrl).toString() |
| 84 | + }) |
96 | 85 | } |
97 | | - return imports |
| 86 | + return item |
98 | 87 | } |
99 | 88 |
|
100 | 89 | export default eventHandler(async (event) => { |
101 | 90 | const url = getRequestURL(event) |
102 | 91 | const registryUrl = url.origin |
103 | | - |
104 | | - const packageDir = join(process.cwd(), '..', '..', 'packages', 'elements') |
105 | | - const srcDir = join(packageDir, 'src') |
106 | | - const examplesDir = join(process.cwd(), '..', '..', 'packages', 'examples', 'src') |
107 | | - |
108 | | - // Read package.json for dependencies |
109 | | - const packageJsonPath = join(packageDir, 'package.json') |
110 | | - const packageJsonRaw = await fs.readFile(packageJsonPath, 'utf-8') |
111 | | - const packageJson = JSON.parse(packageJsonRaw) as { |
112 | | - dependencies?: Record<string, string> |
113 | | - devDependencies?: Record<string, string> |
114 | | - } |
115 | | - |
116 | | - const internalDependencies = Object.keys(packageJson.dependencies || {}).filter( |
117 | | - dep => dep.startsWith('@repo') && dep !== '@repo/shadcn-vue', |
118 | | - ) |
119 | | - |
120 | | - const dependenciesSet = new Set( |
121 | | - Object.keys(packageJson.dependencies || {}).filter( |
122 | | - dep => !['vue', '@repo/shadcn-vue', ...internalDependencies].includes(dep), |
123 | | - ), |
124 | | - ) |
125 | | - |
126 | | - const devDependenciesSet = new Set( |
127 | | - Object.keys(packageJson.devDependencies || {}).filter(dep => !['typescript'].includes(dep)), |
128 | | - ) |
129 | | - |
130 | | - // Ensure commonly required dependencies |
131 | | - dependenciesSet.add('ai') |
132 | | - dependenciesSet.add('@ai-sdk/vue') |
133 | | - dependenciesSet.add('zod') |
134 | | - |
135 | | - const allVueFiles = await walkVueFiles(srcDir) |
136 | | - |
137 | | - const files: RegistryFile[] = [] |
138 | | - |
139 | | - for (const absPath of allVueFiles) { |
140 | | - const content = await fs.readFile(absPath, 'utf-8') |
141 | | - const parsedContent = content |
142 | | - .replace(/@repo\/shadcn-vue\//g, '@/') |
143 | | - .replace(/@repo\/elements\//g, '@/components/ai-elements/') |
144 | | - |
145 | | - const rel = relative(srcDir, absPath).split('\\').join('/') |
146 | | - files.push({ |
147 | | - type: 'registry:component', |
148 | | - path: `registry/default/ai-elements/${rel}`, |
149 | | - content: parsedContent, |
150 | | - target: `components/ai-elements/${rel}`, |
151 | | - }) |
152 | | - } |
153 | | - |
154 | | - // Load example files (optional) |
155 | | - let exampleFiles: RegistryFile[] = [] |
156 | | - try { |
157 | | - const exampleEntries = await fs.readdir(examplesDir, { withFileTypes: true }) |
158 | | - const exampleVue = exampleEntries |
159 | | - .filter(e => e.isFile() && e.name.endsWith('.vue')) |
160 | | - .map(e => join(examplesDir, e.name)) |
161 | | - |
162 | | - exampleFiles = await Promise.all( |
163 | | - exampleVue.map(async (filePath) => { |
164 | | - const content = await fs.readFile(filePath, 'utf-8') |
165 | | - const parsedContent = content.replace(/@repo\/elements\//g, '@/components/ai-elements/') |
166 | | - const name = filePath.split('/').pop() as string |
167 | | - return { |
168 | | - type: 'registry:block', |
169 | | - path: `registry/default/examples/${name}`, |
170 | | - content: parsedContent, |
171 | | - target: `components/ai-elements/examples/${name}`, |
172 | | - } |
173 | | - }), |
174 | | - ) |
175 | | - } |
176 | | - catch { |
177 | | - // examples are optional |
178 | | - } |
179 | | - |
180 | | - files.push(...exampleFiles) |
181 | | - |
182 | | - // Collect component groups from directory names under src |
183 | | - const groupToFiles = new Map<string, RegistryFile[]>() |
184 | | - for (const f of files) { |
185 | | - if (!f.path.startsWith('registry/default/ai-elements/')) |
186 | | - continue |
187 | | - const rel = f.path.replace('registry/default/ai-elements/', '') |
188 | | - const group = rel.split('/')[0] |
189 | | - if (!groupToFiles.has(group)) |
190 | | - groupToFiles.set(group, []) |
191 | | - groupToFiles.get(group)!.push(f) |
192 | | - } |
193 | | - |
194 | | - // Build items |
195 | | - const componentItems: RegistryItemSchema[] = Array.from(groupToFiles.keys()).map(group => ({ |
196 | | - name: group, |
197 | | - type: 'registry:component', |
198 | | - title: toTitle(group), |
199 | | - description: `AI-powered ${group.replace('-', ' ')} components.`, |
200 | | - files: groupToFiles.get(group)!.map(f => ({ path: f.path, type: f.type, target: f.target })), |
201 | | - })) |
202 | | - |
203 | | - const exampleItems: RegistryItemSchema[] = exampleFiles.map((ef) => { |
204 | | - const name = (ef.path.split('/').pop() as string).replace('.vue', '') |
205 | | - return { |
206 | | - name: `example-${name}`, |
207 | | - type: 'registry:block', |
208 | | - title: `${toTitle(name)} Example`, |
209 | | - description: `Example implementation of ${name.replace('-', ' ')}.`, |
210 | | - files: [{ path: ef.path, type: ef.type, target: ef.target }], |
211 | | - } |
212 | | - }) |
213 | | - |
214 | | - const items: RegistryItemSchema[] = [...componentItems, ...exampleItems] |
215 | | - |
216 | | - const response: RegistrySchema = { |
217 | | - $schema: 'https://shadcn-vue.com/schema/registry.json', |
218 | | - name: 'ai-elements-vue', |
219 | | - homepage: new URL('/elements', registryUrl).toString() as unknown as string, |
220 | | - items, |
221 | | - } |
| 92 | + const storage = useStorage('assets:registry') |
222 | 93 |
|
223 | 94 | const componentParam = event.context.params?.component as string | undefined |
224 | 95 | const fallbackFromPath = url.pathname.split('/').pop() || '' |
225 | 96 | const component = componentParam ?? fallbackFromPath |
226 | 97 | const parsedComponent = component.replace('.json', '') |
227 | 98 |
|
228 | | - if (parsedComponent === 'all' || parsedComponent === 'registry') { |
229 | | - return response |
230 | | - } |
| 99 | + if (parsedComponent === 'registry' || parsedComponent === 'all') { |
| 100 | + try { |
| 101 | + const index = await storage.getItem('index.json') as RegistrySchema | null |
| 102 | + if (index) { |
| 103 | + return index |
| 104 | + } |
| 105 | + } |
| 106 | + catch (error) { |
| 107 | + console.error('Failed to load registry/index.json:', error) |
| 108 | + } |
231 | 109 |
|
232 | | - const item = response.items.find(i => i.name === parsedComponent) |
233 | | - if (!item) { |
234 | | - return { error: `Component "${parsedComponent}" not found.` } |
| 110 | + // Fallback: return a basic registry structure |
| 111 | + return { |
| 112 | + $schema: 'https://shadcn-vue.com/schema/registry.json', |
| 113 | + name: 'ai-elements-vue', |
| 114 | + homepage: 'https://ai-elements-vue.com', |
| 115 | + items: [], |
| 116 | + } |
235 | 117 | } |
236 | 118 |
|
237 | | - // Resolve files with content for the selected item |
238 | | - const selectedFiles: RegistryFile[] = [] |
239 | | - if (item.type === 'registry:component') { |
240 | | - for (const f of item.files || []) { |
241 | | - const file = files.find(x => x.path === f.path) |
242 | | - if (file) |
243 | | - selectedFiles.push(file) |
| 119 | + // Try to load component first |
| 120 | + try { |
| 121 | + const componentJson = await storage.getItem(`components/${parsedComponent}.json`) as ItemResponse | null |
| 122 | + if (componentJson) { |
| 123 | + return transformRegistryDependencies(componentJson, registryUrl) |
244 | 124 | } |
245 | 125 | } |
246 | | - else if (item.type === 'registry:block' && parsedComponent.startsWith('example-')) { |
247 | | - const name = `${parsedComponent.replace('example-', '')}.vue` |
248 | | - const file = files.find(x => x.path === `registry/default/examples/${name}`) |
249 | | - if (file) |
250 | | - selectedFiles.push(file) |
251 | | - } |
252 | | - |
253 | | - if (selectedFiles.length === 0) { |
254 | | - return { error: `Files for "${parsedComponent}" not found.` } |
| 126 | + catch (error) { |
| 127 | + console.warn(`Failed to load components/${parsedComponent}.json:`, error) |
255 | 128 | } |
256 | 129 |
|
257 | | - // Analyze imports to compute dependencies |
258 | | - const usedDependencies = new Set<string>() |
259 | | - const usedDevDependencies = new Set<string>() |
260 | | - const usedRegistryDependencies = new Set<string>() |
261 | | - |
262 | | - for (const f of selectedFiles) { |
263 | | - const imports = extractImports(f.content) |
264 | | - for (const moduleName of imports) { |
265 | | - if (!moduleName) |
266 | | - continue |
267 | | - |
268 | | - if (moduleName.startsWith('./')) { |
269 | | - const relativePath = moduleName.split('/').pop() |
270 | | - if (relativePath) { |
271 | | - usedRegistryDependencies.add(new URL(`/${relativePath}.json`, registryUrl).toString()) |
272 | | - } |
273 | | - } |
274 | | - |
275 | | - if (dependenciesSet.has(moduleName)) |
276 | | - usedDependencies.add(moduleName) |
277 | | - if (devDependenciesSet.has(moduleName)) |
278 | | - usedDevDependencies.add(moduleName) |
279 | | - |
280 | | - if (moduleName.startsWith('@/components/ui/')) { |
281 | | - const componentName = moduleName.split('/').pop() |
282 | | - if (componentName) |
283 | | - usedRegistryDependencies.add(componentName) |
284 | | - } |
285 | | - |
286 | | - if (moduleName.startsWith('@/components/ai-elements/')) { |
287 | | - const componentName = moduleName.split('/').pop() |
288 | | - if (componentName) { |
289 | | - usedRegistryDependencies.add(new URL(`/${componentName}.json`, registryUrl).toString()) |
290 | | - } |
291 | | - } |
| 130 | + // Try to load example |
| 131 | + try { |
| 132 | + // If the component name starts with "example-", remove the prefix to find the example file |
| 133 | + const exampleName = parsedComponent.startsWith('example-') |
| 134 | + ? parsedComponent.replace('example-', '') |
| 135 | + : parsedComponent |
| 136 | + const exampleJson = await storage.getItem(`examples/${exampleName}.json`) as ItemResponse | null |
| 137 | + if (exampleJson) { |
| 138 | + return transformRegistryDependencies(exampleJson, registryUrl) |
292 | 139 | } |
293 | 140 | } |
294 | | - |
295 | | - // Add internal dependencies as registry URLs |
296 | | - for (const dep of internalDependencies) { |
297 | | - const packageName = dep.replace('@repo/', '') |
298 | | - usedRegistryDependencies.add(new URL(`/elements/${packageName}.json`, registryUrl).toString()) |
| 141 | + catch (error) { |
| 142 | + console.warn(`Failed to load examples/${parsedComponent}.json:`, error) |
299 | 143 | } |
300 | 144 |
|
301 | | - const itemResponse: ItemResponse = { |
302 | | - $schema: 'https://shadcn-vue.com/schema/registry-item.json', |
303 | | - name: item.name, |
304 | | - type: item.type, |
305 | | - title: item.title, |
306 | | - description: item.description, |
307 | | - files: selectedFiles, |
308 | | - dependencies: Array.from(usedDependencies), |
309 | | - devDependencies: Array.from(usedDevDependencies), |
310 | | - registryDependencies: Array.from(usedRegistryDependencies), |
| 145 | + // Enhanced error message with suggestions |
| 146 | + console.error(`Component "${parsedComponent}" not found in registry`) |
| 147 | + return { |
| 148 | + error: `Component "${parsedComponent}" not found.`, |
| 149 | + suggestions: 'Available endpoints: /registry.json, /all.json, or individual component names', |
311 | 150 | } |
312 | | - |
313 | | - return itemResponse |
314 | 151 | }) |
0 commit comments