Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/docs/app/r/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Dynamic registry route with download tracking.
*
* Serves registry items at /r/[name].json with Umami event tracking.
* Uses generateStaticParams for build-time route generation while
* still running tracking code at request time.
*/

import { type NextRequest, NextResponse } from "next/server"
import { notFound } from "next/navigation"
import { getAllItemNames, buildRegistryItemResponse } from "@/lib/registry"
import { trackEvent } from "@/lib/umami"

interface RouteParams {
params: Promise<{ name: string }>
}

export async function GET(_request: NextRequest, { params }: RouteParams) {
const { name } = await params

if (!name.endsWith(".json")) {
return NextResponse.json(
{ error: "Registry items must end with .json" },
{ status: 400 }
)
}

const itemName = name.replace(".json", "")

// Track download in production (non-blocking)
if (process.env.NODE_ENV === "production") {
trackEvent({
name: "registry-download",
url: `/r/${name}`,
data: { component: itemName },
})
}

try {
const item = buildRegistryItemResponse(itemName)

if (!item) {
notFound()
}

return NextResponse.json(item)
} catch (error) {
console.error(`Failed to serve registry item: ${itemName}`, error)
notFound()
}
}

export async function generateStaticParams() {
const names = getAllItemNames()
return names.map((name) => ({ name: `${name}.json` }))
}
31 changes: 31 additions & 0 deletions apps/docs/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,34 @@ export function getRegistryItemSources(
content: readRegistryFileSource(file.path),
}))
}

/**
* Get all registry item names for static route generation.
*/
export function getAllItemNames(): string[] {
return (registryData.items as RegistryItem[]).map((item) => item.name)
}

/**
* Build a full registry item JSON response with inlined file contents.
* Used by the /r/[name] route handler.
*/
export function buildRegistryItemResponse(name: string): object | null {
const item = getRegistryItem(name)
if (!item) return null

const filesWithContent = item.files.map((file) => {
const filePath = join(process.cwd(), file.path)
const content = readFileSync(filePath, "utf-8")
return {
...file,
content,
}
})

return {
$schema: "https://ui.shadcn.com/schema/registry-item.json",
...item,
files: filesWithContent,
}
}
43 changes: 43 additions & 0 deletions apps/docs/lib/umami.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Umami server-side tracking for registry downloads.
*
* Sends events to Umami without blocking the response.
* Requires UMAMI_HOST_URL and UMAMI_WEBSITE_ID env vars.
*/

interface TrackEventOptions {
name: string
url?: string
data?: Record<string, string | number | boolean>
}

const UMAMI_HOST_URL = process.env.UMAMI_HOST_URL
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID

export async function trackEvent({ name, url, data }: TrackEventOptions): Promise<void> {
if (!UMAMI_HOST_URL || !UMAMI_WEBSITE_ID) {
return
}

try {
await fetch(`${UMAMI_HOST_URL}/api/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "jalco-ui/registry",
},
body: JSON.stringify({
type: "event",
payload: {
website: UMAMI_WEBSITE_ID,
hostname: "ui.justinlevine.me",
url: url ?? "/",
name,
data,
},
}),
})
} catch {
// Silently fail — don't block registry responses for analytics
}
}
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "tsx scripts/generate-preview-imports.ts && next dev",
"build": "shadcn build && tsx scripts/build-registry-index.ts && tsx scripts/generate-preview-imports.ts && next build",
"build": "tsx scripts/build-registry-index.ts && tsx scripts/generate-preview-imports.ts && next build",
"start": "next start",
"lint": "eslint .",
"registry:build": "shadcn build",
Expand Down
Loading