Skip to content

Commit 466d36f

Browse files
committed
refactor(registry): move registry generation to build time
1 parent 12897e8 commit 466d36f

File tree

8 files changed

+427
-249
lines changed

8 files changed

+427
-249
lines changed

apps/registry/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ dist
66
.output
77
.env
88
.env.local
9+
10+
# Registry assets
11+
server/assets/registry

apps/registry/nitro.config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { defineNitroConfig } from 'nitropack/config'
2+
import { buildHooks } from './server/hooks'
23

34
// https://nitro.build/config
45
export default defineNitroConfig({
56
compatibilityDate: 'latest',
67
srcDir: 'server',
7-
imports: false,
88
preset: 'cloudflare',
9+
hooks: buildHooks,
10+
serverAssets: [
11+
{
12+
baseName: 'registry',
13+
dir: './assets/registry',
14+
},
15+
],
916
})

apps/registry/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
"build": "nitro build",
77
"dev": "nitro dev --port 3001"
88
},
9-
"dependencies": {
10-
"ts-morph": "^26.0.0"
11-
},
129
"devDependencies": {
10+
"@vue/compiler-sfc": "^3.5.21",
1311
"h3": "^1.15.4",
14-
"nitropack": "^2.12.4"
12+
"nitropack": "^2.12.4",
13+
"ts-morph": "^27.0.0"
1514
}
1615
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { Nitro } from 'nitropack'
2+
import { generateRegistryAssets } from '../utils/registry-builder'
3+
4+
export const buildHooks = {
5+
'build:before': async (nitro: Nitro) => {
6+
await generateRegistryAssets({
7+
rootDir: nitro.options.rootDir,
8+
})
9+
},
10+
}
Lines changed: 63 additions & 226 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { promises as fs } from 'node:fs'
2-
import { join, relative } from 'node:path'
3-
import process from 'node:process'
41
import { eventHandler, getRequestURL } from 'h3'
2+
import { useStorage } from 'nitropack/runtime'
53

64
interface RegistryFile {
75
type:
@@ -63,252 +61,91 @@ interface ItemResponse {
6361
registryDependencies: string[]
6462
}
6563

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.
8965

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+
})
9685
}
97-
return imports
86+
return item
9887
}
9988

10089
export default eventHandler(async (event) => {
10190
const url = getRequestURL(event)
10291
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')
22293

22394
const componentParam = event.context.params?.component as string | undefined
22495
const fallbackFromPath = url.pathname.split('/').pop() || ''
22596
const component = componentParam ?? fallbackFromPath
22697
const parsedComponent = component.replace('.json', '')
22798

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+
}
231109

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+
}
235117
}
236118

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)
244124
}
245125
}
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)
255128
}
256129

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)
292139
}
293140
}
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)
299143
}
300144

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',
311150
}
312-
313-
return itemResponse
314151
})

0 commit comments

Comments
 (0)