Skip to content

Commit 92d8003

Browse files
committed
feat: add registry API routes and static build for MCP tool
Add Next.js API routes at /api/registry/ for dynamic component queries (server mode). Add build-registry-api.mts script that generates static JSON files at /api/registry/ for CF Pages / static hosting. Endpoints: - /api/registry — component list - /api/registry/components/{name} — component with source - /api/registry/search?q= — search - /api/registry/index — full manifest (single payload) Static files built during `pnpm build` step.
1 parent ced725e commit 92d8003

File tree

7 files changed

+357
-1
lines changed

7 files changed

+357
-1
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* GET /api/registry/components/:name — Get component with full source.
3+
*
4+
* Returns the component JSON including embedded source code.
5+
*/
6+
7+
import { NextRequest, NextResponse } from "next/server"
8+
import { getComponent } from "../../lib"
9+
10+
export async function GET(
11+
_req: NextRequest,
12+
{ params }: { params: Promise<{ name: string }> }
13+
) {
14+
const { name } = await params
15+
const component = getComponent(name)
16+
17+
if (!component) {
18+
return NextResponse.json(
19+
{ error: "Component not found", name },
20+
{ status: 404 }
21+
)
22+
}
23+
24+
return NextResponse.json(component, {
25+
headers: {
26+
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
27+
"Access-Control-Allow-Origin": "*",
28+
},
29+
})
30+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* GET /api/registry/index — Full registry manifest.
3+
*
4+
* Returns ALL components with source in a single payload.
5+
* MCP clients can hydrate their cache with one HTTP call.
6+
*/
7+
8+
import { NextResponse } from "next/server"
9+
import { getFullManifest } from "../lib"
10+
11+
export async function GET() {
12+
const manifest = getFullManifest()
13+
14+
return NextResponse.json(manifest, {
15+
headers: {
16+
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
17+
"Access-Control-Allow-Origin": "*",
18+
},
19+
})
20+
}

app/app/api/registry/lib.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Shared registry data loader for API routes.
3+
*
4+
* Reads from the built registry JSON files in public/registry/.
5+
* These are generated at build time by scripts/build-registry.mts.
6+
*/
7+
8+
import { readFileSync, readdirSync, existsSync } from "fs"
9+
import path from "path"
10+
11+
const REGISTRY_DIR = path.join(process.cwd(), "public/registry")
12+
const STYLES_DIR = path.join(REGISTRY_DIR, "styles/default")
13+
14+
export interface RegistryItem {
15+
name: string
16+
type: string
17+
dependencies?: string[]
18+
devDependencies?: string[]
19+
registryDependencies?: string[]
20+
files: Array<{ name: string; content: string } | string>
21+
description?: string
22+
category?: string
23+
}
24+
25+
// In-memory cache (populated on first access, lives for the server lifetime)
26+
let _index: RegistryItem[] | null = null
27+
let _components: Map<string, RegistryItem> | null = null
28+
29+
function loadIndex(): RegistryItem[] {
30+
if (_index) return _index
31+
const indexPath = path.join(REGISTRY_DIR, "index.json")
32+
if (!existsSync(indexPath)) return []
33+
_index = JSON.parse(readFileSync(indexPath, "utf-8")) as RegistryItem[]
34+
return _index
35+
}
36+
37+
function loadComponents(): Map<string, RegistryItem> {
38+
if (_components) return _components
39+
_components = new Map()
40+
41+
if (!existsSync(STYLES_DIR)) return _components
42+
43+
const files = readdirSync(STYLES_DIR).filter((f) => f.endsWith(".json"))
44+
for (const file of files) {
45+
try {
46+
const data = JSON.parse(
47+
readFileSync(path.join(STYLES_DIR, file), "utf-8")
48+
) as RegistryItem
49+
_components.set(data.name, data)
50+
} catch {
51+
// skip malformed files
52+
}
53+
}
54+
return _components
55+
}
56+
57+
/** Get the full component index (names, types, deps — no source). */
58+
export function getIndex(): RegistryItem[] {
59+
return loadIndex()
60+
}
61+
62+
/** Get all components with full source code. */
63+
export function getComponentMap(): Map<string, RegistryItem> {
64+
return loadComponents()
65+
}
66+
67+
/** Get a single component by name (with source). */
68+
export function getComponent(name: string): RegistryItem | undefined {
69+
const map = loadComponents()
70+
// Try exact match first
71+
let item = map.get(name)
72+
if (item) return item
73+
74+
// Try with -demo suffix stripped
75+
item = map.get(`${name}-demo`)
76+
return item
77+
}
78+
79+
/** Search components by name/type. */
80+
export function searchComponents(query: string): RegistryItem[] {
81+
const q = query.toLowerCase()
82+
const index = loadIndex()
83+
return index.filter(
84+
(item) =>
85+
item.name.toLowerCase().includes(q) ||
86+
item.type?.toLowerCase().includes(q) ||
87+
item.description?.toLowerCase().includes(q) ||
88+
item.category?.toLowerCase().includes(q)
89+
)
90+
}
91+
92+
/** List components filtered by type. */
93+
export function listByType(type?: string): RegistryItem[] {
94+
const index = loadIndex()
95+
if (!type) return index
96+
return index.filter((item) => item.type === type || item.type?.includes(type))
97+
}
98+
99+
/** Get full registry manifest (all components with source — single payload). */
100+
export function getFullManifest() {
101+
const map = loadComponents()
102+
const components: Record<string, any> = {}
103+
for (const [name, item] of map) {
104+
components[name] = item
105+
}
106+
return {
107+
generated_at: Date.now(),
108+
total: map.size,
109+
components,
110+
}
111+
}
112+
113+
/** Invalidate the in-memory cache (call after registry:build). */
114+
export function invalidateCache() {
115+
_index = null
116+
_components = null
117+
}

app/app/api/registry/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* GET /api/registry — List all components (index).
3+
*
4+
* Query params:
5+
* ?type=components:ui — filter by type
6+
*/
7+
8+
import { NextRequest, NextResponse } from "next/server"
9+
import { listByType } from "./lib"
10+
11+
export async function GET(req: NextRequest) {
12+
const type = req.nextUrl.searchParams.get("type") ?? undefined
13+
const items = listByType(type)
14+
15+
return NextResponse.json(
16+
{
17+
total: items.length,
18+
components: items.map((item) => ({
19+
name: item.name,
20+
type: item.type,
21+
dependencies: item.dependencies,
22+
registryDependencies: item.registryDependencies,
23+
})),
24+
},
25+
{
26+
headers: {
27+
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
28+
"Access-Control-Allow-Origin": "*",
29+
},
30+
}
31+
)
32+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* GET /api/registry/search?q=button — Search components.
3+
*/
4+
5+
import { NextRequest, NextResponse } from "next/server"
6+
import { searchComponents } from "../lib"
7+
8+
export async function GET(req: NextRequest) {
9+
const query = req.nextUrl.searchParams.get("q")
10+
11+
if (!query) {
12+
return NextResponse.json(
13+
{ error: "Query parameter 'q' is required" },
14+
{ status: 400 }
15+
)
16+
}
17+
18+
const results = searchComponents(query)
19+
20+
return NextResponse.json(
21+
{
22+
query,
23+
total: results.length,
24+
results: results.map((item) => ({
25+
name: item.name,
26+
type: item.type,
27+
dependencies: item.dependencies,
28+
})),
29+
},
30+
{
31+
headers: {
32+
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
33+
"Access-Control-Allow-Origin": "*",
34+
},
35+
}
36+
)
37+
}

app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "module",
66
"scripts": {
77
"dev": "next dev --port 3333",
8-
"build": "pnpm registry:build && (pnpm registry:capture || echo 'Skipping screenshots in CI') && next build",
8+
"build": "pnpm registry:build && pnpm registry:api && (pnpm registry:capture || echo 'Skipping screenshots in CI') && next build",
99
"start": "next start --port 3001",
1010
"lint": "eslint .",
1111
"lint:fix": "eslint --fix .",
@@ -17,6 +17,7 @@
1717
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
1818
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
1919
"registry:build": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts",
20+
"registry:api": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry-api.mts",
2021
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
2122
"validate:registries": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/validate-registries.mts",
2223
"postinstall": "docs-mdx source.config.ts .source"

app/scripts/build-registry-api.mts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Build static API responses from the registry data.
3+
*
4+
* Generates /public/api/registry/ files that work on CF Pages,
5+
* GitHub Pages, or any static hosting. Run after build-registry.mts.
6+
*
7+
* Output:
8+
* public/api/registry/index.json — full manifest (all components + source)
9+
* public/api/registry/components.json — component list (no source)
10+
* public/api/registry/components/{name}.json — individual component with source
11+
* public/api/registry/search-index.json — lightweight search index
12+
*/
13+
14+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs"
15+
import path from "path"
16+
17+
const REGISTRY_DIR = path.join(process.cwd(), "public/registry")
18+
const STYLES_DIR = path.join(REGISTRY_DIR, "styles/default")
19+
const API_DIR = path.join(process.cwd(), "public/api/registry")
20+
const COMPONENTS_DIR = path.join(API_DIR, "components")
21+
22+
interface RegistryItem {
23+
name: string
24+
type: string
25+
dependencies?: string[]
26+
devDependencies?: string[]
27+
registryDependencies?: string[]
28+
files: Array<{ name: string; content: string } | string>
29+
description?: string
30+
category?: string
31+
}
32+
33+
function main() {
34+
console.log("Building static registry API files...")
35+
36+
// Read the index
37+
const indexPath = path.join(REGISTRY_DIR, "index.json")
38+
if (!existsSync(indexPath)) {
39+
console.error("Registry index.json not found. Run registry:build first.")
40+
process.exit(1)
41+
}
42+
43+
const index: RegistryItem[] = JSON.parse(readFileSync(indexPath, "utf-8"))
44+
45+
// Read all component files
46+
const components = new Map<string, RegistryItem>()
47+
if (existsSync(STYLES_DIR)) {
48+
const files = readdirSync(STYLES_DIR).filter((f) => f.endsWith(".json"))
49+
for (const file of files) {
50+
try {
51+
const data: RegistryItem = JSON.parse(
52+
readFileSync(path.join(STYLES_DIR, file), "utf-8")
53+
)
54+
components.set(data.name, data)
55+
} catch {
56+
// skip
57+
}
58+
}
59+
}
60+
61+
// Create output directories
62+
mkdirSync(COMPONENTS_DIR, { recursive: true })
63+
64+
// 1. Component list (no source — lightweight)
65+
const componentList = index.map((item) => ({
66+
name: item.name,
67+
type: item.type,
68+
dependencies: item.dependencies,
69+
registryDependencies: item.registryDependencies,
70+
}))
71+
72+
writeFileSync(
73+
path.join(API_DIR, "components.json"),
74+
JSON.stringify({ total: componentList.length, components: componentList })
75+
)
76+
console.log(` components.json: ${componentList.length} components`)
77+
78+
// 2. Individual component files (with source)
79+
let written = 0
80+
for (const [name, data] of components) {
81+
writeFileSync(
82+
path.join(COMPONENTS_DIR, `${name}.json`),
83+
JSON.stringify(data)
84+
)
85+
written++
86+
}
87+
console.log(` components/*.json: ${written} files`)
88+
89+
// 3. Full manifest (single payload with all source)
90+
const manifest: Record<string, any> = {}
91+
for (const [name, data] of components) {
92+
manifest[name] = data
93+
}
94+
writeFileSync(
95+
path.join(API_DIR, "index.json"),
96+
JSON.stringify({
97+
generated_at: Date.now(),
98+
total: components.size,
99+
components: manifest,
100+
})
101+
)
102+
console.log(` index.json: full manifest`)
103+
104+
// 4. Search index (lightweight — names + types for client-side search)
105+
const searchIndex = index.map((item) => ({
106+
n: item.name,
107+
t: item.type,
108+
d: (item.dependencies || []).join(","),
109+
}))
110+
writeFileSync(
111+
path.join(API_DIR, "search-index.json"),
112+
JSON.stringify(searchIndex)
113+
)
114+
console.log(` search-index.json: ${searchIndex.length} entries`)
115+
116+
console.log("Done! Static API files written to public/api/registry/")
117+
}
118+
119+
main()

0 commit comments

Comments
 (0)