first commit
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled

This commit is contained in:
Arian Tron
2026-03-10 19:37:31 +03:30
commit 61f56f997c
27684 changed files with 2784175 additions and 0 deletions

41
apps/bundle-analyzer/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Build outputs
/build
/.next/
/out/
# Coverage and test outputs
/coverage
# Generated content
*.tsbuildinfo
next-env.d.ts
.source
# Logs and debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Environment variables
.env*
.env*.local
# Miscellaneous
.DS_Store
*.pem
# Deployment
.vercel

View File

@@ -0,0 +1,20 @@
# Bundle Analyzer
A Next.js application for visualizing bundle sizes and analyzing dependencies using interactive treemaps.
This package is not published to npm. Instead it's built and vendored into the main `next` package during its build process.
## Features
- 📊 **Interactive Treemap**: Visualize bundle sizes with an interactive treemap interface
- 🎯 **Route-based Analysis**: Analyze bundle data for specific routes in your application
- 🔍 **Dependency Tracking**: View import chains and dependency relationships
- 🎨 **Filter Controls**: Filter by environment (client/server) and file types (JS/CSS/JSON/Assets)
- 🔎 **Search Functionality**: Search through files in your bundle
## Updating
When landing non-trivial changes, consider updating the demo site:
* Site: https://turbopack-bundle-analyzer-demo.vercel.sh/
* Repo: https://github.com/vercel/turbopack-bundle-analyzer-demo

View File

@@ -0,0 +1,116 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--polyfill: #5f707f;
--polyfill-foreground: #ffffff;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--polyfill: #5f707f;
--polyfill-foreground: #ffffff;
}
@theme inline {
--font-sans: system-ui, sans-serif;
--font-mono: ui-monospace, monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-polyfill: var(--polyfill);
--color-polyfill-foreground: var(--polyfill-foreground);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,44 @@
import type { Metadata } from 'next'
import type React from 'react'
import './globals.css'
export const metadata: Metadata = {
title: 'Next.js Bundle Analyzer',
description:
'Visualize and analyze your Next.js bundle sizes with interactive treemap and dependency analysis',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang="en"
// Suppress hydration warnings here specifically because we'll modify the
// classname for the theme before React hydates. This is to prevent a flash
// of incorrect theme.
suppressHydrationWarning
>
<head>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: Needed to prevent flash of incorrect theme
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
document.documentElement.classList.toggle('dark', e.matches);
});
})();
`,
}}
/>
</head>
<body className="font-sans antialiased">{children}</body>
</html>
)
}

View File

@@ -0,0 +1,404 @@
'use client'
import type React from 'react'
import { useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import { ErrorState } from '@/components/error-state'
import { FileSearch } from '@/components/file-search'
import { RouteTypeahead } from '@/components/route-typeahead'
import { Sidebar } from '@/components/sidebar'
import { TreemapVisualizer } from '@/components/treemap-visualizer'
import { Badge } from '@/components/ui/badge'
import { TreemapSkeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MultiSelect } from '@/components/ui/multi-select'
import { AnalyzeData, ModulesData } from '@/lib/analyze-data'
import { computeActiveEntries, computeModuleDepthMap } from '@/lib/module-graph'
import { fetchStrict } from '@/lib/utils'
import { formatBytes } from '@/lib/utils'
import { SizeMode } from '@/lib/treemap-layout'
import {
Monitor,
Server,
FileCode,
FileJson,
Palette,
Package,
} from 'lucide-react'
enum Environment {
Client = 'client',
Server = 'server',
}
export default function Home() {
const [selectedRoute, setSelectedRoute] = useState<string | null>(null)
const [environmentFilter, setEnvironmentFilter] = useState<Environment>(
Environment.Client
)
const [typeFilter, setTypeFilter] = useState(['js', 'css', 'json'])
const [selectedSourceIndex, setSelectedSourceIndex] = useState<number | null>(
null
)
const [focusedSourceIndex, setFocusedSourceIndex] = useState<number | null>(
null
)
const {
data: modulesData,
isLoading: isModulesLoading,
error: modulesError,
} = useSWR<ModulesData>('data/modules.data', fetchModulesData)
let analyzeDataPath
if (selectedRoute && selectedRoute === '/') {
analyzeDataPath = 'data/analyze.data'
} else if (selectedRoute) {
analyzeDataPath = `data/${selectedRoute.replace(/^\//, '')}/analyze.data`
} else {
analyzeDataPath = null
}
const {
data: analyzeData,
isLoading: isAnalyzeLoading,
error: analyzeError,
} = useSWR<AnalyzeData>(analyzeDataPath, fetchAnalyzeData, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
onSuccess: (newData) => {
const newRootSourceIndex = getRootSourceIndex(newData)
setSelectedSourceIndex(newRootSourceIndex)
setFocusedSourceIndex(newRootSourceIndex)
},
})
const [sidebarWidth, setSidebarWidth] = useState(20) // percentage
const [isResizing, setIsResizing] = useState(false)
const [isMouseInTreemap, setIsMouseInTreemap] = useState(false)
const [hoveredNodeInfo, setHoveredNodeInfo] = useState<{
name: string
size: number
server?: boolean
client?: boolean
} | null>(null)
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// esc clears current treemap source selection
if (e.key === 'Escape') {
const activeElement = document.activeElement
const isInputFocused =
activeElement && ['INPUT', 'TEXTAREA'].includes(activeElement.tagName)
if (!isInputFocused) {
e.preventDefault()
const rootSourceIndex = getRootSourceIndex(analyzeData)
setSelectedSourceIndex(rootSourceIndex)
setFocusedSourceIndex(rootSourceIndex)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [analyzeData])
// Compute module depth map from active entries
const moduleDepthMap = useMemo(() => {
if (!modulesData || !analyzeData) return new Map()
const activeEntries = computeActiveEntries(modulesData, analyzeData)
return computeModuleDepthMap(modulesData, activeEntries)
}, [modulesData, analyzeData])
const filterSource = useMemo(() => {
if (!analyzeData) return () => true
return (sourceIndex: number) => {
const flags = analyzeData.getSourceFlags(sourceIndex)
// Check environment filter
const hasEnvironment =
(environmentFilter === Environment.Client && flags.client) ||
(environmentFilter === Environment.Server && flags.server)
// Check type filter
const hasType =
(typeFilter.includes('js') && flags.js) ||
(typeFilter.includes('css') && flags.css) ||
(typeFilter.includes('json') && flags.json) ||
(typeFilter.includes('asset') && flags.asset)
return hasEnvironment && hasType
}
}, [analyzeData, environmentFilter, typeFilter])
const handleMouseDown = () => {
setIsResizing(true)
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!isResizing) return
const newWidth = ((window.innerWidth - e.clientX) / window.innerWidth) * 100
setSidebarWidth(Math.max(10, Math.min(50, newWidth))) // Clamp between 10% and 50%
}
const handleMouseUp = () => {
setIsResizing(false)
}
const error = analyzeError || modulesError
const isAnyLoading = isAnalyzeLoading || isModulesLoading
const rootSourceIndex = getRootSourceIndex(analyzeData)
return (
<main
className="h-screen flex flex-col bg-background"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<TopBar
analyzeData={analyzeData}
selectedRoute={selectedRoute}
setSelectedRoute={setSelectedRoute}
environmentFilter={environmentFilter}
setEnvironmentFilter={setEnvironmentFilter}
setSelectedSourceIndex={setSelectedSourceIndex}
setFocusedSourceIndex={setFocusedSourceIndex}
typeFilter={typeFilter}
setTypeFilter={setTypeFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<div className="flex-1 flex min-h-0">
{error && !analyzeData ? (
<ErrorState error={error} />
) : isAnyLoading ? (
<>
<div className="flex-1 min-w-0 p-4 bg-background">
<TreemapSkeleton />
</div>
<button
type="button"
className="flex-none w-1 bg-border cursor-col-resize transition-colors"
disabled
aria-label="Resize sidebar"
/>
<Sidebar
sidebarWidth={sidebarWidth}
analyzeData={null}
modulesData={null}
selectedSourceIndex={null}
moduleDepthMap={new Map()}
environmentFilter={environmentFilter}
isLoading={true}
/>
</>
) : analyzeData ? (
<>
<div className="flex-1 min-w-0">
<TreemapVisualizer
analyzeData={analyzeData}
sourceIndex={rootSourceIndex}
selectedSourceIndex={selectedSourceIndex ?? rootSourceIndex}
onSelectSourceIndex={setSelectedSourceIndex}
focusedSourceIndex={focusedSourceIndex ?? rootSourceIndex}
onFocusSourceIndex={setFocusedSourceIndex}
isMouseInTreemap={isMouseInTreemap}
onMouseInTreemapChange={setIsMouseInTreemap}
onHoveredNodeChange={setHoveredNodeInfo}
searchQuery={searchQuery}
filterSource={filterSource}
sizeMode={SizeMode.Compressed}
/>
</div>
<button
type="button"
className="flex-none w-1 bg-border hover:bg-primary cursor-col-resize transition-colors"
onMouseDown={handleMouseDown}
aria-label="Resize sidebar"
/>
<Sidebar
sidebarWidth={sidebarWidth}
analyzeData={analyzeData ?? null}
modulesData={modulesData ?? null}
selectedSourceIndex={selectedSourceIndex}
moduleDepthMap={moduleDepthMap}
environmentFilter={environmentFilter}
filterSource={filterSource}
/>
</>
) : null}
</div>
{analyzeData && (
<div className="flex-none border-t border-border bg-background px-4 py-2 h-10">
<div className="text-sm text-muted-foreground">
{hoveredNodeInfo ? (
<>
<span className="font-medium text-foreground">
{hoveredNodeInfo.name}
</span>
<span className="ml-2 text-muted-foreground">
{`${formatBytes(hoveredNodeInfo.size)} compressed`}
</span>
{(hoveredNodeInfo.server || hoveredNodeInfo.client) && (
<span className="ml-2 inline-flex gap-1">
{hoveredNodeInfo.client && (
<Badge variant="client">client</Badge>
)}
{hoveredNodeInfo.server && (
<Badge variant="server">server</Badge>
)}
</span>
)}
</>
) : (
'Hover over a file to see details'
)}
</div>
</div>
)}
</main>
)
}
const typeFilterOptions = [
{
value: 'js',
label: 'JavaScript',
icon: <FileCode className="h-3.5 w-3.5" />,
},
{ value: 'css', label: 'CSS', icon: <Palette className="h-3.5 w-3.5" /> },
{
value: 'json',
label: 'JSON',
icon: <FileJson className="h-3.5 w-3.5" />,
},
{
value: 'asset',
label: 'Asset',
icon: <Package className="h-3.5 w-3.5" />,
},
]
function TopBar({
analyzeData,
selectedRoute,
setSelectedRoute,
environmentFilter,
setEnvironmentFilter,
setSelectedSourceIndex,
setFocusedSourceIndex,
typeFilter,
setTypeFilter,
searchQuery,
setSearchQuery,
}: {
analyzeData: AnalyzeData | undefined
selectedRoute: string | null
setSelectedRoute: (route: string | null) => void
environmentFilter: Environment
setEnvironmentFilter: (env: Environment) => void
setSelectedSourceIndex: (index: number | null) => void
setFocusedSourceIndex: (index: number | null) => void
typeFilter: string[]
setTypeFilter: (types: string[]) => void
searchQuery: string
setSearchQuery: (query: string) => void
}) {
return (
<div className="flex-none px-4 py-2 border-b border-border flex items-center gap-3">
<div className="flex-1 flex">
<RouteTypeahead
selectedRoute={selectedRoute}
onRouteSelected={(route) => {
setSelectedRoute(route)
setSelectedSourceIndex(null)
setFocusedSourceIndex(null)
}}
/>
</div>
<div className="flex items-center gap-2">
{analyzeData && (
<>
<Select
value={environmentFilter}
onValueChange={(value: Environment) =>
setEnvironmentFilter(value)
}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={Environment.Client}>
<div className="flex items-center gap-1.5">
<Monitor className="h-3.5 w-3.5" />
<span className="text-xs">Client</span>
</div>
</SelectItem>
<SelectItem value={Environment.Server}>
<div className="flex items-center gap-1.5">
<Server className="h-3.5 w-3.5" />
<span className="text-xs">Server</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<MultiSelect
options={typeFilterOptions}
value={typeFilter}
onValueChange={setTypeFilter}
selectionName={{ singular: 'file type', plural: 'file types' }}
triggerIcon={<FileCode className="h-3.5 w-3.5" />}
triggerClassName="w-36"
aria-label="Filter by file type"
/>
<ControlDivider />
<FileSearch value={searchQuery} onChange={setSearchQuery} />
</>
)}
</div>
</div>
)
}
function ControlDivider() {
return <span className="h-6 w-px bg-muted-foreground/30" />
}
function getRootSourceIndex(analyzeData: AnalyzeData | undefined): number {
if (!analyzeData) return 0
const sourceRoots = analyzeData.sourceRoots()
return sourceRoots.length > 0 ? sourceRoots[0] : 0
}
async function fetchAnalyzeData(url: string): Promise<AnalyzeData> {
const resp = await fetchStrict(url)
return new AnalyzeData(await resp.arrayBuffer())
}
async function fetchModulesData(url: string): Promise<ModulesData> {
const resp = await fetchStrict(url)
return new ModulesData(await resp.arrayBuffer())
}

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,42 @@
'use client'
import { RefreshCw, AlertTriangle } from 'lucide-react'
import { NetworkError } from '@/lib/errors'
interface ErrorStateProps {
error: unknown
onRetry?: () => void
}
export function ErrorState({ error }: ErrorStateProps) {
const isNetwork = error instanceof NetworkError
const title = isNetwork ? 'Server Connection Lost' : 'Error'
const message = isNetwork
? 'Unable to connect to the bundle analyzer server. Please ensure the server is running and try again.'
: ((error as any)?.message ?? 'An unexpected error occurred.')
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md px-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10 text-destructive mb-2">
<AlertTriangle className="w-8 h-8" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{title}
</h3>
<p className="text-sm text-muted-foreground mb-4">{message}</p>
<button
onClick={() => {
window.location.reload()
}}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
<RefreshCw className="w-4 h-4" />
Retry Connection
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { Kbd } from '@/components/ui/kbd'
interface FileSearchProps {
value: string
onChange: (value: string) => void
}
export function FileSearch({ value, onChange }: FileSearchProps) {
const [focused, setFocused] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key === '/' &&
!['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)
) {
e.preventDefault()
inputRef.current?.focus()
} else if (
e.key === 'Escape' &&
document.activeElement === inputRef.current
) {
e.preventDefault()
onChange('')
inputRef.current?.blur()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onChange])
const handleFocus = () => {
setFocused(true)
}
const handleBlur = () => {
setFocused(false)
}
return (
<div className="relative">
<Input
ref={inputRef}
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Search files..."
className="w-48 focus:w-80 transition-all duration-200 pr-8"
/>
{!value && !focused && (
<Kbd className="absolute right-2 top-1/2 -translate-y-1/2">/</Kbd>
)}
</div>
)
}

View File

@@ -0,0 +1,538 @@
'use client'
import {
ArrowUp,
ChevronLeft,
ChevronRight,
Box,
File,
PanelTop,
SquareFunction,
Server,
Globe,
MessageCircleQuestion,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import type {
AnalyzeData,
ModuleIndex,
ModulesData,
SourceIndex,
} from '@/lib/analyze-data'
import { splitIdent } from '@/lib/utils'
import clsx from 'clsx'
import { Button } from '@/components/ui/button'
interface ImportChainProps {
startFileId: number
analyzeData: AnalyzeData
modulesData: ModulesData
depthMap: Map<ModuleIndex, number>
environmentFilter: 'client' | 'server'
}
interface ChainLevel {
moduleIndex: ModuleIndex
sourceIndex: SourceIndex | undefined
path: string
depth: number
fullPath?: string
templateArgs?: string
layer?: string
moduleType?: string
treeShaking?: string
selectedIndex: number
totalCount: number
// Info about this level's relationship to parent (undefined for root)
info?: DependentInfo
}
interface DependentInfo {
moduleIndex: number
sourceIndex: number | undefined
ident: string
isAsync: boolean
depth: number
}
type PathPart = {
segment: string
isCommon: boolean
isLastCommon: boolean
isPackageName: boolean
isInfrastructure: boolean
}
function spitPathSegments(path: string): string[] {
return Array.from(path.matchAll(/(.+?(?:\/|$))/g)).map(([i]) => i)
}
function getPathParts(
currentPath: string,
previousPath: string | null
): PathPart[] {
const currentSegments = spitPathSegments(currentPath)
let commonCount = 0
if (previousPath) {
const previousSegments = spitPathSegments(previousPath)
const minLength = Math.min(currentSegments.length, previousSegments.length)
for (let i = 0; i < minLength; i++) {
if (currentSegments[i] === previousSegments[i]) {
commonCount++
} else {
break
}
}
}
let infrastructureCount = 0
let packageNameCount = 0
let nodeModulesIndex = currentSegments.lastIndexOf('node_modules/')
if (nodeModulesIndex === -1) {
nodeModulesIndex = currentSegments.length
} else {
infrastructureCount = nodeModulesIndex + 1
if (currentSegments[nodeModulesIndex + 1]?.startsWith('@')) {
packageNameCount = 2
} else {
packageNameCount = 1
}
}
return currentSegments.map((segment, i) => ({
segment,
isCommon: i < commonCount,
isLastCommon: i === commonCount - 1,
isInfrastructure: i < infrastructureCount,
isPackageName:
i >= infrastructureCount && i < infrastructureCount + packageNameCount,
}))
}
function getTitle(level: ChainLevel) {
const parts = []
if (level.fullPath) parts.push(`Full Path: ${level.fullPath}`)
else parts.push(`Path: ${level.path}`)
if (level.layer) parts.push(`Layer: ${level.layer}`)
if (level.moduleType) parts.push(`Module Type: ${level.moduleType}`)
if (level.treeShaking) parts.push(`Tree Shaking: ${level.treeShaking}`)
if (level.templateArgs) parts.push(`Template Args: ${level.templateArgs}`)
return parts.join('\n')
}
export function ImportChain({
startFileId,
analyzeData,
modulesData,
depthMap,
environmentFilter,
}: ImportChainProps) {
// Filter to include only the current route
const [currentRouteOnly, setCurrentRouteOnly] = useState(true)
// Track which dependent is selected at each level
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
// Helper function to get module indices from source path
const getModuleIndicesFromSourceIndex = (sourceIndex: number) => {
const path = analyzeData.getFullSourcePath(sourceIndex)
return modulesData.getModuleIndiciesFromPath(path)
}
// Helper function to get source index from module path
const getSourceIndexFromModuleIndex = (moduleIndex: number) => {
const module = modulesData.module(moduleIndex)
if (!module) return undefined
// Search through all sources to find one with matching path
const modulePath = module.path
for (let i = 0; i < analyzeData.sourceCount(); i++) {
if (analyzeData.getFullSourcePath(i) === modulePath) {
return i
}
}
return undefined
}
// Build the import chain based on current selections
const chain = useMemo(() => {
const result: ChainLevel[] = []
const visitedModules = new Set<number>()
const startPath = analyzeData.getFullSourcePath(startFileId)
if (!startPath) return result
// Get all module indices for the starting source
const startModuleIndices = getModuleIndicesFromSourceIndex(
startFileId
).filter((moduleIndex) => {
if (currentRouteOnly && !depthMap.has(moduleIndex)) {
return false
}
let module = modulesData.module(moduleIndex)
let layer = splitIdent(module?.ident || '').layer
if (layer) {
if (environmentFilter === 'client' && /ssr|rsc|route|api/.test(layer)) {
return false
}
if (environmentFilter === 'server' && /client/.test(layer)) {
return false
}
}
return true
})
if (startModuleIndices.length === 0) return result
// Get the selected index for the start modules (default to 0)
const selectedStartIdx = selectedIndices[0] ?? 0
const actualStartIdx = Math.min(
selectedStartIdx,
startModuleIndices.length - 1
)
const startModuleIndex = startModuleIndices[actualStartIdx]
const startIdent = modulesData.module(startModuleIndex)?.ident ?? ''
result.push({
moduleIndex: startModuleIndex,
sourceIndex: startFileId,
path: startPath,
...splitIdent(startIdent),
depth: depthMap.get(startModuleIndex) ?? Infinity,
selectedIndex: actualStartIdx,
totalCount: startModuleIndices.length,
})
visitedModules.add(startModuleIndex)
// Build chain by following selected dependents
let levelIndex = 1
let currentModuleIndex = startModuleIndex
while (true) {
// Get dependents at the module level (sync and async)
const dependentModuleIndices = [
...modulesData
.moduleDependents(currentModuleIndex)
.map((index: number) => ({
index,
async: false,
depth: depthMap.get(index) ?? Infinity,
})),
...modulesData
.asyncModuleDependents(currentModuleIndex)
.map((index: number) => ({
index,
async: true,
depth: depthMap.get(index) ?? Infinity,
})),
]
// Filter out dependents that would create a cycle
const validDependents = dependentModuleIndices.filter(
({ index, depth }) =>
!visitedModules.has(index) && (isFinite(depth) || !currentRouteOnly)
)
if (validDependents.length === 0) {
// No more dependents or all would create cycles
break
}
// Build info for each dependent
const dependentsInfo: DependentInfo[] = validDependents.map(
({ index: moduleIndex, async: isAsync, depth }) => {
const sourceIndex = getSourceIndexFromModuleIndex(moduleIndex)
let ident = modulesData.module(moduleIndex)?.ident || ''
return {
moduleIndex,
sourceIndex,
ident,
isAsync,
depth,
}
}
)
// Sort: sync first, async second, then by source presence, then by depth
dependentsInfo.sort((a, b) => {
// Sort by depth (smallest first)
if (a.depth !== b.depth) {
return a.depth - b.depth
}
// Sort by ident length (shortest first)
if (a.ident.length !== b.ident.length) {
return a.ident.length - b.ident.length
}
// Sort by ident
return a.ident.localeCompare(b.ident)
})
// Get the selected index for this level (default to 0)
const selectedIdx = selectedIndices[levelIndex] ?? 0
const actualIdx = Math.min(selectedIdx, dependentsInfo.length - 1)
const selectedDepInfo = dependentsInfo[actualIdx]
const selectedDepModule = modulesData.module(selectedDepInfo.moduleIndex)
if (!selectedDepModule || selectedDepModule.ident == null) break
result.push({
moduleIndex: selectedDepInfo.moduleIndex,
sourceIndex: selectedDepInfo.sourceIndex,
path: selectedDepModule.path,
depth: depthMap.get(selectedDepInfo.moduleIndex) ?? Infinity,
...splitIdent(selectedDepModule.ident),
selectedIndex: actualIdx,
totalCount: dependentsInfo.length,
info: selectedDepInfo,
})
visitedModules.add(selectedDepInfo.moduleIndex)
currentModuleIndex = selectedDepInfo.moduleIndex
levelIndex++
// Safety check to prevent infinite loops
if (levelIndex > 100) break
}
return result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
startFileId,
analyzeData,
modulesData,
selectedIndices,
currentRouteOnly,
depthMap,
environmentFilter,
])
const handlePrevious = (levelIndex: number) => {
setSelectedIndices((prev) => {
const newIndices = [...prev]
const currentIdx = newIndices[levelIndex] ?? 0
const level = chain[levelIndex]
newIndices[levelIndex] =
currentIdx > 0 ? currentIdx - 1 : level.totalCount - 1
return newIndices.slice(0, levelIndex + 1)
})
}
const handleNext = (levelIndex: number) => {
setSelectedIndices((prev) => {
const newIndices = [...prev]
const currentIdx = newIndices[levelIndex] ?? 0
const level = chain[levelIndex]
newIndices[levelIndex] =
currentIdx < level.totalCount - 1 ? currentIdx + 1 : 0
return newIndices.slice(0, levelIndex + 1)
})
}
const startPath = analyzeData.getFullSourcePath(startFileId)
if (!startPath) return null
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<h3 className="text-xs font-semibold text-foreground flex-1">
Import Chain
</h3>
<label
className="inline-flex items-center space-x-2 text-xs cursor-pointer"
title="Only include dependent modules that are part of the current route's bundle"
>
<span>Current route only</span>
<input
type="checkbox"
className="form-checkbox h-4 w-4 text-primary"
checked={currentRouteOnly}
onChange={() => setCurrentRouteOnly((prev) => !prev)}
/>
</label>
</div>
<div className="space-y-0">
{chain.map((level, index) => {
const previousPath = index > 0 ? chain[index - 1].path : null
const parts = getPathParts(level.path, previousPath)
const flags =
level.sourceIndex !== undefined
? analyzeData.getSourceFlags(level.sourceIndex)
: undefined
// Get the current item's info from the level itself
const currentItemInfo = level.info
return (
<div key={`${level.path}-${index}`}>
{currentItemInfo?.isAsync && <div className="h-8" />}
<div className="flex items-center justify-center gap-2 py-1">
{currentItemInfo?.isAsync && (
<span className="text-xs text-muted-foreground italic">
(async)
</span>
)}
{index > 0 ? (
<ArrowUp className="w-4 h-4 text-muted-foreground" />
) : undefined}
{level.totalCount > 1 && (
<div className="flex items-center gap-1 flex-none">
<button
type="button"
onClick={() => handlePrevious(index)}
className="p-0.5 hover:bg-accent rounded transition-colors cursor-pointer"
title="Previous dependent"
>
<ChevronLeft className="w-3 h-3" />
</button>
<span className="text-muted-foreground text-xs min-w-[3ch] text-center">
{level.selectedIndex + 1}/{level.totalCount}
</span>
<button
type="button"
onClick={() => handleNext(index)}
className="p-0.5 hover:bg-accent rounded transition-colors cursor-pointer"
title="Next dependent"
>
<ChevronRight className="w-3 h-3" />
</button>
</div>
)}
</div>
{currentItemInfo?.isAsync && <div className="h-8" />}
<div className="flex items-center gap-2">
<div className="flex flex-col gap-1 items-center">
{!level.layer ? (
<div title="Unknown">
<MessageCircleQuestion className="w-3 h-3 text-gray-500" />
</div>
) : /app/.test(level.layer || '') ? (
<div title="App Router">
<Box className="w-3 h-3 text-green-500" />
</div>
) : (
<div title="Pages Router">
<File className="w-3 h-3 text-purple-500" />
</div>
)}
</div>
<div className="flex-1 border border-border rounded px-2 py-1 bg-background">
<span
className="font-mono text-xs text-foreground text-center block break-words"
title={getTitle(level)}
>
{parts.map(
(
{
segment,
isCommon,
isLastCommon,
isInfrastructure,
isPackageName,
},
i
) => (
<span
key={i}
className={clsx(
segment.length > 20 && 'break-all',
isCommon &&
!isLastCommon &&
!isPackageName &&
!isInfrastructure &&
'text-muted-foreground/80',
!isCommon && !isInfrastructure && 'font-bold',
isInfrastructure && 'text-muted-foreground/50',
isPackageName && 'text-orange-500'
)}
>
{segment}
<wbr />
</span>
)
)}
</span>
</div>
{/* Show icons for current item if we have flag info or no source */}
<div className="flex flex-col gap-1 items-center">
{/client/.test(level.layer || '') &&
(flags?.client ? (
<div title="Included on client-side of this route">
<PanelTop className="w-3 h-3 text-green-500" />
</div>
) : (
<div title="On client layer, but not included on client-side of this route (might be optimized away)">
<PanelTop className="w-3 h-3 text-gray-500" />
</div>
))}
{/ssr/.test(level.layer || '') &&
(flags?.server ? (
<div title="Included on server-side of this route for server-side rendering">
<Globe className="w-3 h-3 text-blue-500" />
</div>
) : (
<div title="On server-side rendering layer, but not included on server-side of this route (might be optimized away)">
<Globe className="w-3 h-3 text-gray-500" />
</div>
))}
{/rsc/.test(level.layer || '') &&
(flags?.server ? (
<div title="Included on server-side of this route as Server Component">
<Server className="w-3 h-3 text-orange-500" />
</div>
) : (
<div title="On Server Component layer, but not included on server-side of this route (might be optimized away)">
<Server className="w-3 h-3 text-gray-500" />
</div>
))}
{/route|api/.test(level.layer || '') &&
(flags?.server ? (
<div title="Included on server-side of this route for API routes">
<SquareFunction className="w-3 h-3 text-red-500" />
</div>
) : (
<div title="On API route layer, but not included on server-side of this route (might be optimized away)">
<SquareFunction className="w-3 h-3 text-gray-500" />
</div>
))}
</div>
</div>
<div className="block text-center">
{level.treeShaking && (
<div className="text-xs text-foreground text-center">
{level.treeShaking === 'locals'
? '(local declarations only)'
: level.treeShaking === 'module evaluation'
? '(only the module evaluation part of the module)'
: `(${level.treeShaking})`}
</div>
)}
</div>
</div>
)
})}
{chain.length === 0 && (
<p className="text-muted-foreground italic text-xs">
No dependents found.{' '}
{currentRouteOnly ? (
<Button
variant="link"
className="inline p-0 text-xs h-auto"
onClick={() => setCurrentRouteOnly(false)}
>
Show all routes.
</Button>
) : null}
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { ImportChain } from './import-chain'
export { RouteTypeahead } from './route-typeahead'
export { TreemapVisualizer } from './treemap-visualizer'
export { ErrorState } from './error-state'

View File

@@ -0,0 +1,156 @@
'use client'
import useSWR from 'swr'
import { Check, ChevronsUpDown, Loader, Route } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn, jsonFetcher } from '@/lib/utils'
import { NetworkError } from '@/lib/errors'
import { Kbd } from '@/components/ui/kbd'
interface RouteTypeaheadProps {
selectedRoute: string | null
onRouteSelected: (routeName: string) => void
}
export function RouteTypeahead({
selectedRoute,
onRouteSelected,
}: RouteTypeaheadProps) {
const [open, setOpen] = useState(false)
const [shortcutLabel, setShortcutLabel] = useState<string | null>(null)
useEffect(() => {
const isAppleDevice = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
setShortcutLabel(isAppleDevice ? '⌘K' : 'Ctrl+K')
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement
const isInputFocused =
activeElement && ['INPUT', 'TEXTAREA'].includes(activeElement.tagName)
if (isInputFocused) return
const isShortcutPressed = isAppleDevice
? e.metaKey && e.key === 'k'
: e.ctrlKey && e.key === 'k'
if (isShortcutPressed) {
e.preventDefault()
setOpen(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
const {
data: routes,
isLoading,
error,
} = useSWR<string[]>('data/routes.json', jsonFetcher, {
onSuccess: (routeNames) => {
// Auto-select first route if none is selected
if (routeNames.length > 0 && selectedRoute == null) {
onRouteSelected(routeNames[0])
}
},
})
if (error) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-destructive text-sm max-w-full">
<span className="font-medium"></span>
<span className="truncate">
{error instanceof NetworkError
? 'Unable to connect to server'
: error.message}
</span>
</div>
)
}
let ctaText
if (isLoading) {
ctaText = 'Loading routes...'
} else if (selectedRoute != null) {
ctaText = selectedRoute
} else {
ctaText = 'Select route...'
}
return (
<div className="flex items-center gap-2 min-w-64 max-w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={isLoading}
className="flex-grow-1 w-full justify-between font-mono text-sm"
>
<div className="flex items-center">
{isLoading ? (
<Loader className="mr-2 inline animate-spin" />
) : (
<Route className="inline mr-2" />
)}
{ctaText}
</div>
<div className="flex items-center gap-2">
{shortcutLabel && <Kbd>{shortcutLabel}</Kbd>}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0">
<Command>
<CommandInput placeholder="Search routes..." className="h-9" />
<CommandList>
<CommandEmpty>No route found.</CommandEmpty>
<CommandGroup>
{(routes || []).map((route) => {
return (
<CommandItem
key={route}
value={route}
onSelect={() => {
onRouteSelected(route)
setOpen(false)
}}
className="font-mono"
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedRoute === route ? 'opacity-100' : 'opacity-0'
)}
/>
{route}
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -0,0 +1,237 @@
'use client'
import type React from 'react'
import { CircleHelp } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './ui/tooltip'
import { ImportChain } from '@/components/import-chain'
import { Skeleton } from '@/components/ui/skeleton'
import { AnalyzeData, ModulesData } from '@/lib/analyze-data'
import { SpecialModule } from '@/lib/types'
import { getSpecialModuleType } from '@/lib/utils'
import { Badge } from './ui/badge'
interface SidebarProps {
sidebarWidth: number
analyzeData: AnalyzeData | null
modulesData: ModulesData | null
selectedSourceIndex: number | null
moduleDepthMap: Map<number, number>
environmentFilter: 'client' | 'server'
filterSource?: (sourceIndex: number) => boolean
isLoading?: boolean
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
export function Sidebar({
sidebarWidth,
analyzeData,
modulesData,
selectedSourceIndex,
moduleDepthMap,
environmentFilter,
filterSource,
isLoading = false,
}: SidebarProps) {
filterSource = filterSource ?? (() => true)
if (isLoading || !analyzeData) {
return (
<div
className="flex-none bg-muted border-l border-border overflow-y-auto"
style={{ width: `${sidebarWidth}%` }}
>
<div className="flex-1 p-3 space-y-4 overflow-y-auto">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<div className="mt-4 space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</div>
</div>
</div>
)
}
return (
<div
className="flex-none bg-muted border-l border-border overflow-y-auto"
style={{ width: `${sidebarWidth}%` }}
>
{selectedSourceIndex != null ? (
<SelectionDetails
analyzeData={analyzeData}
modulesData={modulesData}
selectedSourceIndex={selectedSourceIndex}
filterSource={filterSource}
moduleDepthMap={moduleDepthMap}
environmentFilter={environmentFilter}
/>
) : null}
</div>
)
}
function SelectionDetails({
analyzeData,
modulesData,
selectedSourceIndex,
filterSource,
moduleDepthMap,
environmentFilter,
}: {
analyzeData: AnalyzeData
modulesData: ModulesData | null
selectedSourceIndex: number
moduleDepthMap: Map<number, number>
environmentFilter: 'client' | 'server'
filterSource: (sourceIndex: number) => boolean
}) {
const specialModuleType = getSpecialModuleType(
analyzeData,
selectedSourceIndex
)
const selectedSource =
selectedSourceIndex != null
? analyzeData.source(selectedSourceIndex)
: undefined
const hasChildModules =
selectedSourceIndex != null &&
analyzeData.sourceChildren(selectedSourceIndex).length > 0
const childModuleCount =
hasChildModules && selectedSourceIndex != null
? analyzeData.getRecursiveModuleCount(selectedSourceIndex, filterSource)
: null
const { size, compressedSize } = analyzeData.getRecursiveSizes(
selectedSourceIndex,
filterSource
)
const chunks =
selectedSourceIndex != null
? analyzeData.sourceChunks(selectedSourceIndex)
: []
return (
<div className="flex-1 p-3 space-y-8 overflow-y-auto">
<div className="space-y-2">
<h2 className="text-s font-semibold mb-1 text-foreground truncate">
{selectedSource?.path || 'All Route Modules'}
</h2>
{selectedSourceIndex != null &&
analyzeData.source(selectedSourceIndex) ? (
<div className="text-xs">
<div>
<span>{formatBytes(compressedSize)}</span>
<span className="text-muted-foreground ml-1">
compressed (estimated)
</span>
<InlineHelpTooltip>
Estimated compressed size. Modules are compressed in isolation
which may differ from their size in the final chunk.
</InlineHelpTooltip>
</div>
<div>
<span>{formatBytes(size)}</span>{' '}
<span className="text-muted-foreground">uncompressed</span>
<InlineHelpTooltip>
Uncompressed modules may still be minified, tree-shaken, and
dead-code eliminated. They just don't account for general
compression like gzip.
</InlineHelpTooltip>
</div>
{hasChildModules && childModuleCount != null ? (
<div>
<span>{childModuleCount} </span>
<span className="text-muted-foreground">
{childModuleCount === 1 ? 'module' : 'modules'}
</span>
</div>
) : null}
</div>
) : null}
</div>
{selectedSourceIndex != null &&
analyzeData.source(selectedSourceIndex) &&
(specialModuleType === SpecialModule.POLYFILL_MODULE ||
specialModuleType === SpecialModule.POLYFILL_NOMODULE) && (
<dl className="flex items-center gap-2">
<dt className="inline-flex items-center">
<Badge variant="polyfill">Polyfill</Badge>
</dt>
<dd className="text-xs text-muted-foreground">
Next.js built-in polyfills
</dd>
</dl>
)}
{selectedSourceIndex != null &&
analyzeData.source(selectedSourceIndex) &&
!hasChildModules && (
<>
{modulesData && (
<ImportChain
startFileId={selectedSourceIndex}
analyzeData={analyzeData}
modulesData={modulesData}
depthMap={moduleDepthMap}
environmentFilter={environmentFilter}
/>
)}
{chunks.length > 0 ? (
<div className="mt-2">
<p className="text-xs font-semibold text-foreground">
Output Chunks
</p>
<ul className="text-xs text-muted-foreground font-mono mt-1 space-y-1">
{chunks.map((chunk) => (
<li key={chunk} className="break-all">
{chunk}
</li>
))}
</ul>
</div>
) : null}
</>
)}
</div>
)
}
function InlineHelpTooltip({ children }: { children: React.ReactNode }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp
size={14}
className="inline-block ml-1 text-muted-foreground"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent className="max-w-xs" side="top" align="center">
{children}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,10 @@
'use client'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
client:
'border-transparent bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-700/10 dark:ring-blue-300/20',
server:
'border-transparent bg-purple-50 dark:bg-purple-950 text-purple-700 dark:text-purple-300 ring-1 ring-inset ring-purple-700/10 dark:ring-purple-300/20',
polyfill:
'border-transparent bg-polyfill/10 dark:bg-polyfill/30 text-polyfill dark:text-polyfill-foreground ring-1 ring-inset ring-polyfill/20',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,59 @@
'use client'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,184 @@
'use client'
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,143 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -0,0 +1,187 @@
'use client'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ChevronDown, CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface MultiSelectPropOption {
value: string
label: string
icon?: React.ReactNode
}
interface MultiSelectProps {
options: MultiSelectPropOption[]
value: string[]
onValueChange: (value: string[]) => void
placeholder?: string
emptyMessage?: string
selectionName?: {
singular: string
plural: string
}
className?: string
triggerClassName?: string
triggerIcon?: React.ReactNode
'aria-label'?: string
size?: 'sm' | 'default'
}
export function MultiSelect({
options,
value,
onValueChange,
placeholder = 'Select items...',
selectionName = {
singular: 'item',
plural: 'items',
},
className,
triggerClassName,
triggerIcon,
'aria-label': ariaLabel,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
function handleToggle(optionValue: string, checked: boolean) {
const newValue = checked
? [...value, optionValue]
: value.filter((v) => v !== optionValue)
if (newValue.length > 0) {
onValueChange(newValue)
}
}
let displayText: string
if (value.length === options.length) {
displayText = `All ${selectionName.plural}`
} else if (value.length === 0) {
displayText = placeholder
} else {
displayText = `${value.length} ${value.length === 1 ? selectionName.singular : selectionName.plural}`
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'justify-between border focus-visible:ring-[3px] focus-visible:ring-ring/50 font-normal px-3 py-2 h-9 text-xs',
triggerClassName
)}
role="combobox"
aria-expanded={open}
aria-haspopup="dialog"
aria-label={ariaLabel}
onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !open) {
e.preventDefault()
setOpen(true)
}
}}
>
<div className="flex items-center gap-1">
{triggerIcon && (
<span
className="shrink-0 text-muted-foreground"
aria-hidden="true"
>
{triggerIcon}
</span>
)}
<span>{displayText}</span>
</div>
<ChevronDown className="h-3.5 w-3.5 opacity-50" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('w-48 p-2', className)}
align="end"
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
setOpen(false)
return
}
const checkboxes = Array.from(
e.currentTarget.querySelectorAll('input[type="checkbox"]')
) as HTMLInputElement[]
const currentIndex = checkboxes.indexOf(
document.activeElement as HTMLInputElement
)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (currentIndex === -1) {
checkboxes[0]?.focus()
} else {
const nextIndex =
e.key === 'ArrowDown'
? (currentIndex + 1) % checkboxes.length
: (currentIndex - 1 + checkboxes.length) % checkboxes.length
checkboxes[nextIndex]?.focus()
}
}
}}
>
<fieldset className="space-y-2">
<legend className="sr-only">{ariaLabel || placeholder}</legend>
{options.map((option) => (
<MultiSelectOption
key={option.value}
label={option.label}
icon={option.icon}
checked={value.includes(option.value)}
onChange={(checked) => handleToggle(option.value, checked)}
/>
))}
</fieldset>
</PopoverContent>
</Popover>
)
}
function MultiSelectOption({
label,
icon,
checked,
onChange,
}: {
label: string
icon?: React.ReactNode
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<label
className={cn(
"text-xs hover:bg-accent hover:text-accent-foreground has-[input:focus-visible]:bg-accent has-[input:focus-visible]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 select-none outline-hidden [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
)}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only peer focus:outline-hidden focus-visible:outline-2"
aria-label={label}
/>
{icon && (
<span className="shrink-0" aria-hidden="true">
{icon}
</span>
)}
<span className="flex items-center gap-2">{label}</span>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
{checked && <CheckIcon className="size-4" />}
</span>
</label>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,190 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'item-aligned',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,33 @@
import { cn } from '@/lib/utils'
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export function TreemapSkeleton() {
return (
<div className="h-full w-full grid grid-cols-12 grid-rows-8 gap-2">
{/* Simulate treemap blocks with varying sizes */}
<Skeleton className="col-span-5 row-span-4" />
<Skeleton className="col-span-4 row-span-3" />
<Skeleton className="col-span-3 row-span-3" />
<Skeleton className="col-span-4 row-span-1" />
<Skeleton className="col-span-3 row-span-2" />
<Skeleton className="col-span-3 row-span-4" />
<Skeleton className="col-span-2 row-span-2" />
<Skeleton className="col-span-2 row-span-2" />
<Skeleton className="col-span-3 row-span-2" />
<Skeleton className="col-span-4 row-span-2" />
<Skeleton className="col-span-2 row-span-2" />
<Skeleton className="col-span-3 row-span-2" />
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import * as React from 'react'
import { cn } from '@/lib/utils'
const ToggleGroupContext = React.createContext<{
size?: 'default' | 'sm' | 'lg'
variant?: 'default' | 'outline'
}>({
size: 'default',
variant: 'default',
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & {
size?: 'default' | 'sm' | 'lg'
variant?: 'default' | 'outline'
}
>(
(
{ className, variant = 'default', size = 'default', children, ...props },
ref
) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
)
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item>
>(({ className, children, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
context.size === 'default' && 'h-10 px-3',
context.size === 'sm' && 'h-8 px-2 text-xs',
context.size === 'lg' && 'h-11 px-5',
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,30 @@
'use client'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import * as React from 'react'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md outline-none data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,485 @@
// Type definitions matching the Rust structures from analyze.rs
// Type aliases for better readability
export type ModuleIndex = number
export type SourceIndex = number
export interface AnalyzeModule {
ident: string
path: string
}
export interface AnalyzeSource {
parent_source_index: number | null
path: string
}
export interface AnalyzeChunkPart {
source_index: number
output_file_index: number
size: number
compressed_size: number
}
export interface AnalyzeOutputFile {
filename: string
}
export interface AnalyzeLayer {
name: string
}
interface EdgesDataReference {
offset: number
length: number
}
interface AnalyzeDataHeader {
sources: AnalyzeSource[]
chunk_parts: AnalyzeChunkPart[]
output_files: AnalyzeOutputFile[]
output_file_chunk_parts: EdgesDataReference
source_chunk_parts: EdgesDataReference
source_children: EdgesDataReference
source_roots: number[]
}
interface ModulesDataHeader {
modules: AnalyzeModule[]
module_dependents: EdgesDataReference
async_module_dependents: EdgesDataReference
module_dependencies: EdgesDataReference
async_module_dependencies: EdgesDataReference
}
/**
* Represents the global modules data that is shared across all routes
*/
export class ModulesData {
private modulesHeader: ModulesDataHeader
private modulesBinaryData: DataView
private pathToModuleIndex: Map<string, ModuleIndex[]>
constructor(modulesArrayBuffer: ArrayBuffer) {
// Parse modules.data
const modulesDataView = new DataView(modulesArrayBuffer)
const modulesJsonLength = modulesDataView.getUint32(0, false)
const modulesJsonBytes = new Uint8Array(
modulesArrayBuffer,
4,
modulesJsonLength
)
const modulesJsonString = new TextDecoder('utf-8').decode(modulesJsonBytes)
this.modulesHeader = JSON.parse(modulesJsonString) as ModulesDataHeader
const modulesBinaryOffset = 4 + modulesJsonLength
this.modulesBinaryData = new DataView(
modulesArrayBuffer,
modulesBinaryOffset
)
// Build pathToModuleIndex map
this.pathToModuleIndex = new Map()
for (let i = 0; i < this.modulesHeader.modules.length; i++) {
const module = this.modulesHeader.modules[i]
const existing = this.pathToModuleIndex.get(module.path)
if (existing) {
existing.push(i)
} else {
this.pathToModuleIndex.set(module.path, [i])
}
}
}
module(index: ModuleIndex): AnalyzeModule | undefined {
return this.modulesHeader.modules[index]
}
moduleCount(): number {
return this.modulesHeader.modules.length
}
getModuleIndiciesFromPath(path: string): ModuleIndex[] {
return this.pathToModuleIndex.get(path) ?? []
}
// Read edges data for a specific index only
private readEdgesDataAtIndex(
reference: EdgesDataReference,
index: ModuleIndex
): ModuleIndex[] {
const { offset, length } = reference
if (length === 0) {
return []
}
// Read the number of offset entries (first u32)
const numOffsets = this.modulesBinaryData.getUint32(offset, false)
if (index < 0 || index >= numOffsets) {
return []
}
// Read only the two offsets we need
const offsetsStart = offset + 4
const prevOffset =
index === 0
? 0
: this.modulesBinaryData.getUint32(
offsetsStart + (index - 1) * 4,
false
)
const currentOffset = this.modulesBinaryData.getUint32(
offsetsStart + index * 4,
false
)
const edgeCount = currentOffset - prevOffset
if (edgeCount === 0) {
return []
}
// Read only the data for this index
const dataStart = offset + 4 + numOffsets * 4
const edges: number[] = []
for (let j = 0; j < edgeCount; j++) {
const edgeValue = this.modulesBinaryData.getUint32(
dataStart + (prevOffset + j) * 4,
false
)
edges.push(edgeValue)
}
return edges
}
moduleDependents(index: ModuleIndex): ModuleIndex[] {
return this.readEdgesDataAtIndex(
this.modulesHeader.module_dependents,
index
)
}
asyncModuleDependents(index: ModuleIndex): ModuleIndex[] {
return this.readEdgesDataAtIndex(
this.modulesHeader.async_module_dependents,
index
)
}
moduleDependencies(index: ModuleIndex): ModuleIndex[] {
return this.readEdgesDataAtIndex(
this.modulesHeader.module_dependencies,
index
)
}
asyncModuleDependencies(index: ModuleIndex): ModuleIndex[] {
return this.readEdgesDataAtIndex(
this.modulesHeader.async_module_dependencies,
index
)
}
getRawModulesHeader(): ModulesDataHeader {
return this.modulesHeader
}
}
/**
* Represents route-specific analyze data
*/
export class AnalyzeData {
private analyzeHeader: AnalyzeDataHeader
private analyzeBinaryData: DataView
private pathToSourceIndex: Map<string, SourceIndex>
constructor(analyzeArrayBuffer: ArrayBuffer) {
// Parse analyze.data
const analyzeDataView = new DataView(analyzeArrayBuffer)
const analyzeJsonLength = analyzeDataView.getUint32(0, false)
const analyzeJsonBytes = new Uint8Array(
analyzeArrayBuffer,
4,
analyzeJsonLength
)
const analyzeJsonString = new TextDecoder('utf-8').decode(analyzeJsonBytes)
this.analyzeHeader = JSON.parse(analyzeJsonString) as AnalyzeDataHeader
const analyzeBinaryOffset = 4 + analyzeJsonLength
this.analyzeBinaryData = new DataView(
analyzeArrayBuffer,
analyzeBinaryOffset
)
// Build pathToSourceIndex map
this.pathToSourceIndex = new Map()
for (let i = 0; i < this.analyzeHeader.sources.length; i++) {
const fullPath = this.getFullSourcePath(i)
this.pathToSourceIndex.set(fullPath, i)
}
}
// Accessor methods for header data
source(index: SourceIndex): AnalyzeSource | undefined {
return this.analyzeHeader.sources[index]
}
sourceCount(): number {
return this.analyzeHeader.sources.length
}
getSourceIndexFromPath(path: string): SourceIndex | undefined {
return this.pathToSourceIndex.get(path)
}
chunkPart(index: number): AnalyzeChunkPart | undefined {
return this.analyzeHeader.chunk_parts[index]
}
chunkPartCount(): number {
return this.analyzeHeader.chunk_parts.length
}
outputFile(index: number): AnalyzeOutputFile | undefined {
return this.analyzeHeader.output_files[index]
}
outputFileCount(): number {
return this.analyzeHeader.output_files.length
}
sourceRoots(): SourceIndex[] {
return this.analyzeHeader.source_roots
}
// Methods to read edges data from the binary section
// Read edges data for a specific index only
private readEdgesDataAtIndex(
reference: EdgesDataReference,
index: SourceIndex
): SourceIndex[] {
const { offset, length } = reference
if (length === 0) {
return []
}
// Read the number of offset entries (first u32)
const numOffsets = this.analyzeBinaryData.getUint32(offset, false)
if (index < 0 || index >= numOffsets) {
return []
}
// Read only the two offsets we need
const offsetsStart = offset + 4
const prevOffset =
index === 0
? 0
: this.analyzeBinaryData.getUint32(
offsetsStart + (index - 1) * 4,
false
)
const currentOffset = this.analyzeBinaryData.getUint32(
offsetsStart + index * 4,
false
)
const edgeCount = currentOffset - prevOffset
if (edgeCount === 0) {
return []
}
// Read only the data for this index
const dataStart = offset + 4 + numOffsets * 4
const edges: number[] = []
for (let j = 0; j < edgeCount; j++) {
const edgeValue = this.analyzeBinaryData.getUint32(
dataStart + (prevOffset + j) * 4,
false
)
edges.push(edgeValue)
}
return edges
}
outputFileChunkParts(index: number): number[] {
return this.readEdgesDataAtIndex(
this.analyzeHeader.output_file_chunk_parts,
index
)
}
sourceChunkParts(index: SourceIndex): number[] {
return this.readEdgesDataAtIndex(
this.analyzeHeader.source_chunk_parts,
index
)
}
sourceChildren(index: SourceIndex): SourceIndex[] {
return this.readEdgesDataAtIndex(this.analyzeHeader.source_children, index)
}
// Utility method to get the full path of a source by walking up the parent chain
getFullSourcePath(index: SourceIndex): string {
const source = this.source(index)
if (!source) return ''
if (source.parent_source_index === null) {
return source.path
}
const parentPath = this.getFullSourcePath(source.parent_source_index)
return parentPath + source.path
}
getOwnSizes(index: SourceIndex): {
size: number
compressedSize: number
} {
const chunkParts = this.sourceChunkParts(index)
let size = 0
let compressedSize = 0
for (const chunkPartIndex of chunkParts) {
const chunkPart = this.chunkPart(chunkPartIndex)
if (chunkPart) {
size += chunkPart.size
compressedSize += chunkPart.compressed_size
}
}
return { size, compressedSize }
}
getRecursiveModuleCount(
index: SourceIndex,
filterSource: (sourceIndex: SourceIndex) => boolean
): number {
const selfVisible = filterSource(index)
const selfCount =
selfVisible && this.sourceChunkParts(index).length > 0 ? 1 : 0
const children = this.sourceChildren(index)
if (children.length === 0) {
return selfCount
}
let totalCount = selfCount
for (const childIndex of children) {
totalCount += this.getRecursiveModuleCount(childIndex, filterSource)
}
return totalCount
}
sourceChunks(index: SourceIndex): string[] {
const chunkParts = this.sourceChunkParts(index)
const uniqueChunks = new Set<string>()
for (const chunkPartIndex of chunkParts) {
const chunkPart = this.chunkPart(chunkPartIndex)
if (chunkPart) {
const outputFile = this.outputFile(chunkPart.output_file_index)
if (outputFile) {
uniqueChunks.add(outputFile.filename)
}
}
}
return Array.from(uniqueChunks).sort()
}
getRecursiveSizes(
index: SourceIndex,
filterSource: (sourceIndex: SourceIndex) => boolean
): { size: number; compressedSize: number } {
let size = 0
let compressedSize = 0
if (filterSource(index)) {
const { size: ownUncompressedSize, compressedSize: ownCompressedSize } =
this.getOwnSizes(index)
size += ownUncompressedSize
compressedSize += ownCompressedSize
}
for (const childIndex of this.sourceChildren(index)) {
const {
size: childUncompressedSize,
compressedSize: childCompressedSize,
} = this.getRecursiveSizes(childIndex, filterSource)
size += childUncompressedSize
compressedSize += childCompressedSize
}
return {
size,
compressedSize,
}
}
getSourceFlags(index: SourceIndex): {
client: boolean
server: boolean
traced: boolean
js: boolean
css: boolean
json: boolean
asset: boolean
} {
let client = false
let server = false
let traced = false
let js = false
let css = false
let json = false
let asset = false
const chunkParts = this.sourceChunkParts(index)
for (const chunkPartIndex of chunkParts) {
const chunkPart = this.chunkPart(chunkPartIndex)
if (!chunkPart) continue
const outputFile = this.outputFile(chunkPart.output_file_index)
if (!outputFile) continue
if (outputFile.filename.startsWith('[client-fs]/')) {
client = true
} else if (outputFile.filename.startsWith('[project]/')) {
traced = true
} else {
server = true
}
if (outputFile.filename.endsWith('.js')) {
js = true
} else if (outputFile.filename.endsWith('.css')) {
css = true
} else if (outputFile.filename.endsWith('.json')) {
json = true
} else {
asset = true
}
}
return { client, server, traced, js, css, json, asset }
}
isPolyfillModule(index: SourceIndex): boolean {
const fullSourcePath = this.getFullSourcePath(index)
return fullSourcePath.endsWith(
'node_modules/next/dist/build/polyfills/polyfill-module.js'
)
}
isPolyfillNoModule(index: SourceIndex): boolean {
const fullSourcePath = this.getFullSourcePath(index)
return fullSourcePath.endsWith(
'node_modules/next/dist/build/polyfills/polyfill-nomodule.js'
)
}
// Get the raw header for debugging
getRawAnalyzeHeader(): AnalyzeDataHeader {
return this.analyzeHeader
}
}

View File

@@ -0,0 +1,10 @@
export class NetworkError extends Error {
constructor(message: string, options?: { cause?: unknown }) {
super(message)
this.name = 'NetworkError'
// Preserve error cause when supported
if (options && 'cause' in options) {
;(this as any).cause = options.cause
}
}
}

View File

@@ -0,0 +1,121 @@
import type { LayoutRect } from './treemap-layout'
export function layoutTreemap(sizes: number[], rect: LayoutRect): LayoutRect[] {
if (sizes.length === 0) return []
if (sizes.length === 1) return [rect]
const totalSize = sizes.reduce((a, b) => a + b, 0)
const normalizedSizes = sizes.map(
(s) => (s / totalSize) * rect.width * rect.height
)
const result: LayoutRect[] = []
let remaining = [...normalizedSizes]
let currentRect = { ...rect }
let totalRemaining = remaining.reduce((a, b) => a + b, 0)
while (remaining.length > 1) {
// Decide orientation: vertical if wider, horizontal if taller
const vertical = currentRect.width >= currentRect.height
// Pick items until sum > total / count
const picked: number[] = []
let sum = 0
for (const size of remaining) {
picked.push(size)
sum += size
if (vertical) {
const width = (currentRect.width * sum) / totalRemaining
if (width > (currentRect.height / picked.length) * 0.9) {
break
}
} else {
const height = (currentRect.height * sum) / totalRemaining
if (height > (currentRect.width / picked.length) * 0.9) {
break
}
}
}
// Ensure at least one item is picked
if (picked.length === 0) {
picked.push(remaining[0])
sum = remaining[0]
}
// Calculate the space used by this row/column
const spaceRatio = sum / totalRemaining
totalRemaining -= sum
if (vertical) {
// Items stacked vertically, filling full width
const rowWidth = Math.round(spaceRatio * currentRect.width)
let offsetY = 0
for (let i = 0; i < picked.length; i++) {
const size = picked[i]
const itemHeight =
i === picked.length - 1
? Math.round(currentRect.height - offsetY)
: Math.round((size / sum) * currentRect.height)
result.push({
x: Math.round(currentRect.x),
y: Math.round(currentRect.y + offsetY),
width: rowWidth,
height: itemHeight,
})
offsetY += itemHeight
}
// Update remaining rectangle
currentRect = {
x: Math.round(currentRect.x + rowWidth),
y: Math.round(currentRect.y),
width: Math.round(currentRect.width - rowWidth),
height: Math.round(currentRect.height),
}
} else {
// Items placed horizontally, filling full height
const rowHeight = Math.round(spaceRatio * currentRect.height)
let offsetX = 0
for (let i = 0; i < picked.length; i++) {
const size = picked[i]
const itemWidth =
i === picked.length - 1
? Math.round(currentRect.width - offsetX)
: Math.round((size / sum) * currentRect.width)
result.push({
x: Math.round(currentRect.x + offsetX),
y: Math.round(currentRect.y),
width: itemWidth,
height: rowHeight,
})
offsetX += itemWidth
}
// Update remaining rectangle
currentRect = {
x: Math.round(currentRect.x),
y: Math.round(currentRect.y + rowHeight),
width: Math.round(currentRect.width),
height: Math.round(currentRect.height - rowHeight),
}
}
// Remove picked items from remaining
remaining = remaining.slice(picked.length)
}
// Last item fills remaining space
if (remaining.length === 1) {
result.push(currentRect)
}
return result
}

View File

@@ -0,0 +1,146 @@
import type { AnalyzeData, ModuleIndex, ModulesData } from './analyze-data'
/**
* Compute active entries from the current route's sources.
*
* It's a heuristic approach that looks for known entry module idents
* and traces their dependencies to find active modules.
*
* I don't like it as it has too much assumptions about next.js internals.
* It would be better if the source map contains idents instead of only paths.
*/
export function computeActiveEntries(
modulesData: ModulesData,
analyzeData: AnalyzeData
): ModuleIndex[] {
const potentialEntryDependents = [
'next/dist/esm/build/templates/pages.js',
'next/dist/esm/build/templates/pages-api.js',
'next/dist/esm/build/templates/pages-edge-api.js',
'next/dist/esm/build/templates/edge-ssr.js',
'next/dist/esm/build/templates/app-route.js',
'next/dist/esm/build/templates/edge-app-route.js',
'next/dist/esm/build/templates/app-page.js',
'next/dist/esm/build/templates/edge-ssr-app.js',
'next/dist/esm/build/templates/middleware.js',
'[next]/entry/page-loader.ts',
]
const potentialEntries = [
'next/dist/client/app-next-turbopack.js',
'next/dist/client/next-turbopack.js',
]
const activeEntries = new Set<ModuleIndex>()
for (
let moduleIndex = 0;
moduleIndex < modulesData.moduleCount();
moduleIndex++
) {
const ident = modulesData.module(moduleIndex)?.ident
if (ident == null) {
continue
}
if (
potentialEntryDependents.some((entryIdent) => ident.includes(entryIdent))
) {
const dependencies = modulesData.moduleDependencies(moduleIndex)
for (const dep of dependencies) {
const path = modulesData.module(dep)!.path
if (path.includes('next/dist/')) {
continue
}
const source = analyzeData.getSourceIndexFromPath(path)
if (source !== undefined) {
activeEntries.add(dep)
}
}
}
if (potentialEntries.some((entryIdent) => ident.includes(entryIdent))) {
activeEntries.add(moduleIndex)
}
}
return Array.from(activeEntries)
}
/**
* Compute module depth from active entries using BFS
* Returns a Map from ModuleIndex to depth
* Unreachable modules will not have an entry in the map
*/
export function computeModuleDepthMap(
modulesData: ModulesData,
activeEntries: ModuleIndex[]
): Map<ModuleIndex, number> {
const depthMap = new Map<ModuleIndex, number>()
const delayedModules = new Array<{ depth: number; queue: ModuleIndex[] }>()
// Initialize queue with active entries
for (const moduleIndex of activeEntries) {
depthMap.set(moduleIndex, 0)
}
// BFS to compute depth
// We need to insert new entries into the depth map in monotonic increasing order of depth
// so that we always process shallower modules before deeper ones
// This is important to avoid visiting modules multiple times and needing to decrease their depth
let i = 0
for (const [moduleIndex, depth] of depthMap) {
const newDepth = depth + 1
// Process regular dependencies
const dependencies = modulesData.moduleDependencies(moduleIndex)
for (const depIndex of dependencies) {
if (!depthMap.has(depIndex)) {
depthMap.set(depIndex, newDepth)
}
}
// Process async dependencies with higher depth penalty
const asyncDependencies = modulesData.asyncModuleDependencies(moduleIndex)
for (const depIndex of asyncDependencies) {
if (!depthMap.has(depIndex)) {
const newDepth = depth + 1000
// We can't directly insert async dependencies into the depth map
// because they might be processed before their parent module
// leading to incorrect depth assignment.
// Instead, we queue them to be processed later.
let delayedQueue = delayedModules.find((dq) => dq.depth === newDepth)
if (!delayedQueue) {
delayedQueue = { depth: newDepth, queue: [] }
delayedModules.push(delayedQueue)
// Keep delayed queues sorted by depth descending
delayedModules.sort((a, b) => b.depth - a.depth)
}
delayedQueue.queue.push(depIndex)
}
}
i++
// Check if we need to process the next delayed queue to insert its items into the depth map
// This happens when we reach the end of the current queue
// or the next delayed queue has the same depth so its items need to be processed now
while (
delayedModules.length > 0 &&
(i === depthMap.size ||
newDepth === delayedModules[delayedModules.length - 1].depth)
) {
const { depth, queue } = delayedModules.pop()!
for (const depIndex of queue) {
if (!depthMap.has(depIndex)) {
depthMap.set(depIndex, depth)
}
}
}
}
if (delayedModules.length > 0) {
throw new Error(
'Internal error: delayed modules remain after BFS processing'
)
}
return depthMap
}

View File

@@ -0,0 +1,294 @@
import type { AnalyzeData, SourceIndex } from './analyze-data'
import { layoutTreemap } from './layout-treemap'
import { SpecialModule } from './types'
import { getSpecialModuleType } from './utils'
export interface LayoutRect {
x: number
y: number
width: number
height: number
}
export interface LayoutNodeInfo {
name: string
size: number
server?: boolean
client?: boolean
}
export interface LayoutNode extends LayoutNodeInfo {
size: number
rect: LayoutRect
type: 'file' | 'directory' | 'collapsed-directory'
specialModuleType: SpecialModule | null
titleBarHeight?: number
children?: LayoutNode[]
itemCount?: number
traced?: boolean
js?: boolean
css?: boolean
json?: boolean
asset?: boolean
sourceIndex?: SourceIndex // Track which source this node represents
}
interface SourceMetadata {
filtered: boolean
size: number
compressedSize: number
}
export enum SizeMode {
Compressed = 'compressed',
Uncompressed = 'uncompressed',
}
function precomputeSourceMetadata(
analyzeData: AnalyzeData,
filterSource?: (sourceIndex: SourceIndex) => boolean
): SourceMetadata[] {
const sourceCount = analyzeData.sourceCount()
const metadata: SourceMetadata[] = new Array(sourceCount)
for (let i = sourceCount - 1; i >= 0; i--) {
const children = analyzeData.sourceChildren(i)
const ownSize = analyzeData.getOwnSizes(i)
if (children.length === 0) {
// file
metadata[i] = {
size: ownSize.size,
compressedSize: ownSize.compressedSize,
filtered: filterSource ? !filterSource(i) : false,
}
} else {
// directory
metadata[i] = {
filtered: true,
size: ownSize.size,
compressedSize: ownSize.compressedSize,
}
}
}
// Top-down pass: aggregate child sizes and filtered status for directories
function processDirectory(idx: SourceIndex) {
const children = analyzeData.sourceChildren(idx)
if (children.length === 0) return // Already processed as leaf
let totalUncompressedSize = metadata[idx].size
let totalCompressedSize = metadata[idx].compressedSize
let hasVisibleChild = false
for (const childIdx of children) {
processDirectory(childIdx) // Process child first
if (!metadata[childIdx].filtered) {
// Only add size of visible (non-filtered) children
totalUncompressedSize += metadata[childIdx].size
totalCompressedSize += metadata[childIdx].compressedSize
hasVisibleChild = true
}
}
metadata[idx].size = totalUncompressedSize
metadata[idx].compressedSize = totalCompressedSize
metadata[idx].filtered = !hasVisibleChild // Directory filtered if no visible children
}
// Process from root sources
const roots = analyzeData.sourceRoots()
for (const rootIdx of roots) {
processDirectory(rootIdx)
}
return metadata
}
// Internal function that uses precomputed metadata
function computeTreemapLayoutFromAnalyzeInternal(
analyzeData: AnalyzeData,
sourceIndex: SourceIndex,
foldedPath: string,
rect: LayoutRect,
metadata: SourceMetadata[],
filterSource: ((sourceIndex: SourceIndex) => boolean) | undefined,
sizeMode: SizeMode
): LayoutNode {
const source = analyzeData.source(sourceIndex)
if (!source) {
throw new Error(`Source at index ${sourceIndex} not found`)
}
const isDirectory = source.path.endsWith('/') || !source.path
const childrenIndices = analyzeData.sourceChildren(sourceIndex)
// Fold single-child directories
if (
childrenIndices.length === 1 &&
isDirectory &&
(foldedPath + source.path).length <= 40
) {
const childIndex = childrenIndices[0]
const child = analyzeData.source(childIndex)
if (child?.path.endsWith('/')) {
return computeTreemapLayoutFromAnalyzeInternal(
analyzeData,
childIndex,
foldedPath + source.path,
rect,
metadata,
filterSource,
sizeMode
)
}
}
const totalSize =
sizeMode === SizeMode.Compressed
? metadata[sourceIndex].compressedSize
: metadata[sourceIndex].size
// If this is a file (no children), create a file node
if (!isDirectory || childrenIndices.length === 0) {
return {
name: source.path,
size: totalSize,
type: 'file',
rect,
sourceIndex,
specialModuleType: getSpecialModuleType(analyzeData, sourceIndex),
...analyzeData.getSourceFlags(sourceIndex),
}
}
const directoryName = foldedPath + source.path || 'All Route Modules'
// Directory with children
const titleBarHeight = Math.round(
Math.max(12, Math.min(24, rect.height * 0.1))
)
const isCollapsed = rect.height < 30
const contentRect: LayoutRect = {
x: Math.round(rect.x),
y: Math.round(rect.y + titleBarHeight),
width: Math.max(0, Math.round(rect.width - 2)),
height: Math.max(0, Math.round(rect.height - titleBarHeight - 2)),
}
if (isCollapsed) {
// Count all descendant files
function countDescendants(idx: SourceIndex): number {
const children = analyzeData.sourceChildren(idx)
if (children.length === 0) return 1
return children.reduce(
(sum, childIdx) => sum + countDescendants(childIdx),
0
)
}
return {
name: directoryName,
size: totalSize,
type: 'collapsed-directory',
rect,
titleBarHeight,
itemCount: countDescendants(sourceIndex),
children: [],
sourceIndex,
specialModuleType: null,
}
}
// Recursively build children with their sizes
const childrenData: { index: number; size: number }[] = []
for (const childIndex of childrenIndices) {
const childSource = analyzeData.source(childIndex)
if (!childSource) continue
// Use precomputed filter status
if (metadata[childIndex].filtered) {
continue
}
// Use precomputed size based on mode
const childSize =
sizeMode === SizeMode.Compressed
? metadata[childIndex].compressedSize
: metadata[childIndex].size
childrenData.push({
index: childIndex,
size: childSize || 1, // Fallback to 1 for visibility
})
}
if (childrenData.length === 0) {
return {
name: directoryName,
size: totalSize,
type: 'directory',
rect,
titleBarHeight,
children: [],
sourceIndex,
specialModuleType: null,
}
}
// Sort by size (descending)
childrenData.sort((a, b) => b.size - a.size)
// Compute layout
const sizes = childrenData.map((c) => c.size)
const childRects = layoutTreemap(sizes, contentRect)
const layoutChildren: LayoutNode[] = childrenData.map((child, i) =>
computeTreemapLayoutFromAnalyzeInternal(
analyzeData,
child.index,
'',
childRects[i],
metadata,
filterSource,
sizeMode
)
)
return {
name: directoryName,
size: totalSize,
type: 'directory',
rect,
titleBarHeight,
children: layoutChildren,
sourceIndex,
specialModuleType: null,
}
}
// Public function that precomputes metadata and calls internal function
export function computeTreemapLayoutFromAnalyze(
analyzeData: AnalyzeData,
sourceIndex: SourceIndex,
rect: LayoutRect,
filterSource?: (sourceIndex: SourceIndex) => boolean,
sizeMode: SizeMode = SizeMode.Compressed
): LayoutNode {
// Precompute metadata once for entire tree
const metadata = precomputeSourceMetadata(analyzeData, filterSource)
// Use internal function with precomputed metadata
return computeTreemapLayoutFromAnalyzeInternal(
analyzeData,
sourceIndex,
'',
rect,
metadata,
filterSource,
sizeMode
)
}

View File

@@ -0,0 +1,40 @@
export interface FileNode {
id: number
name: string
type: 'file'
size: number
outputType: 'code' | 'css' | 'asset'
server: boolean
client: boolean
dependencies: number[]
dependents: number[]
}
export interface DirectoryNode {
id: number
name: string
type: 'directory'
children: TreeNode[]
size?: number
}
export type TreeNode = FileNode | DirectoryNode
interface Route {
page: string
regex: string
routeKeys?: Record<string, string>
namedRegex?: string
}
export interface RouteManifest {
version: number
pages: Record<string, string>
staticRoutes: Array<Route>
dynamicRoutes: Array<Route>
}
export enum SpecialModule {
POLYFILL_MODULE,
POLYFILL_NOMODULE,
}

View File

@@ -0,0 +1,69 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { SpecialModule } from './types'
import { NetworkError } from './errors'
import { AnalyzeData, SourceIndex } from './analyze-data'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export async function fetchStrict(url: string): Promise<Response> {
let res: Response
try {
res = await fetch(url)
} catch (err) {
throw new NetworkError(`Failed to fetch ${url}`, { cause: err })
}
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`)
}
return res
}
export async function jsonFetcher<T>(url: string): Promise<T> {
const res = await fetchStrict(url)
return res.json() as Promise<T>
}
export function getSpecialModuleType(
analyzeData: AnalyzeData | undefined,
sourceIndex: SourceIndex | null
): SpecialModule | null {
if (!analyzeData || sourceIndex == null) return null
const path = analyzeData.source(sourceIndex)?.path || ''
if (path.endsWith('polyfill-module.js')) {
return SpecialModule.POLYFILL_MODULE
} else if (path.endsWith('polyfill-nomodule.js')) {
return SpecialModule.POLYFILL_NOMODULE
}
return null
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
let IDENT_ATTRIBUTES_REGEXP =
/^(.+?)(?: \{(.*)\})?(?: \[(.*)\])?(?: \((.*?)\))?(?: <(.*?)>)?$/
export function splitIdent(ident: string): {
fullPath: string
templateArgs: string
layer: string
moduleType: string
treeShaking: string
} {
let [match, fullPath, templateArgs, layer, moduleType, treeShaking] =
IDENT_ATTRIBUTES_REGEXP.exec(ident) || ['']
ident = ident.substring(0, ident.length - match.length)
return { fullPath, templateArgs, layer, moduleType, treeShaking }
}

View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
distDir: 'dist',
}
export default nextConfig

View File

@@ -0,0 +1,44 @@
{
"name": "@next/bundle-analyzer-ui",
"version": "16.0.2-canary.16",
"private": true,
"scripts": {
"build": "cross-env NEXT_TEST_NATIVE_IGNORE_LOCAL_INSTALL=true NEXT_TEST_NATIVE_DIR=no next build --no-mangling",
"dev": "cross-env NEXT_TEST_NATIVE_IGNORE_LOCAL_INSTALL=true NEXT_TEST_NATIVE_DIR=no next dev",
"lint": "eslint .",
"start": "cross-env NEXT_TEST_NATIVE_IGNORE_LOCAL_INSTALL=true NEXT_TEST_NATIVE_DIR=no next start"
},
"dependencies": {
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.4",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"lucide-react": "^0.554.0",
"next": "16.0.8",
"next-themes": "^0.4.6",
"polished": "^4.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"swr": "^2.2.4",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^18",
"@types/react-dom": "^18",
"cross-env": "10.1.0",
"inliner": "1.13.1",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"dist/types/**/*.ts",
"dist/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}

41
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Build outputs
/build
/.next/
/out/
# Coverage and test outputs
/coverage
# Generated content
*.tsbuildinfo
next-env.d.ts
.source
# Logs and debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Environment variables
.env*
.env*.local
# Miscellaneous
.DS_Store
*.pem
# Deployment
.vercel

17
apps/docs/README.md Normal file
View File

@@ -0,0 +1,17 @@
## Next.js Documentation (WIP)
This app will be the new home for the Next.js documentation, currently in active development.
### What to Expect
- Content is being migrated from our internal monorepo
- The design is not yet final
- Internal tooling and structure may change
### Contributing
We'll open this app to contributions once the initial migration is complete. In the meantime, feel free to follow along or open an issue with the `documentation` tag.
### Feedback
For questions or early feedback, contact [Delba](https://github.com/delbaoliveira) or [Joseph](https://github.com/icyJoseph).

View File

@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"singleQuote": true,
"semi": false
}

View File

@@ -0,0 +1,58 @@
import { source } from '@/lib/source'
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from 'fumadocs-ui/page'
import { notFound } from 'next/navigation'
import { createRelativeLink } from 'fumadocs-ui/mdx'
import { getMDXComponents } from '@/mdx-components'
export default async function Page(props: {
params: Promise<{ slug?: string[] }>
}) {
const params = await props.params
const page = source.getPage(params.slug)
if (!page) notFound()
const MDXContent = page.data.body
const isApp = params.slug?.includes('01-app')
const isPages = params.slug?.includes('02-pages')
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDXContent
components={getMDXComponents(
{
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
},
{ isApp, isPages }
)}
/>
</DocsBody>
</DocsPage>
)
}
export async function generateStaticParams() {
return source.generateParams()
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>
}) {
const params = await props.params
const page = source.getPage(params.slug)
if (!page) notFound()
return {
title: page.data.title,
description: page.data.description,
}
}

View File

@@ -0,0 +1,12 @@
import { DocsLayout } from 'fumadocs-ui/layouts/docs'
import type { ReactNode } from 'react'
import { baseOptions } from '@/app/layout.config'
import { source } from '@/lib/source'
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
)
}

BIN
apps/docs/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

3
apps/docs/app/global.css Normal file
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';

View File

@@ -0,0 +1,18 @@
import { NEXTJS } from '@/constants/brand'
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'
/**
* Shared layout configurations
*
* you can customize layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export const baseOptions: BaseLayoutProps = {
nav: {
title: <>{NEXTJS}</>,
},
// see https://fumadocs.dev/docs/ui/navigation/links
links: [],
}

18
apps/docs/app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import './global.css'
import { RootProvider } from 'fumadocs-ui/provider'
import { Inter } from 'next/font/google'
import type { ReactNode } from 'react'
const inter = Inter({
subsets: ['latin'],
})
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
)
}

65
apps/docs/app/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
import Image from 'next/image'
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black font-[family-name:var(--font-geist-sans)]">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{' '}
<a
href="https://vercel.com/templates?framework=next.js"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{' '}
or the{' '}
<a
href="https://nextjs.org/learn"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{' '}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1 @@
export const NEXTJS = 'Next.js'

View File

@@ -0,0 +1,16 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
]
export default eslintConfig

9
apps/docs/lib/source.ts Normal file
View File

@@ -0,0 +1,9 @@
import { docs } from '@/.source'
import { loader } from 'fumadocs-core/source'
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/docs',
source: docs.toFumadocsSource(),
})

View File

@@ -0,0 +1,88 @@
import defaultMdxComponents from 'fumadocs-ui/mdx'
import type { MDXComponents } from 'mdx/types'
import NextImage, { type ImageProps } from 'next/image'
import { ReactNode } from 'react'
const IMAGE_BASE_URL = ''
const genericComponents = {
Image: (
props: ImageProps & {
srcLight?: string
srcDark?: string
alt?: string
caption?: string
src?: string
}
) => {
const {
src,
srcLight,
srcDark,
caption,
alt = caption || '',
...rest
} = props
const hasThemeVariants = srcLight && srcDark
const sharedClasses = 'rounded-md border border-gray-200 bg-gray-100'
return (
<figure>
{/* Image variants (srcLight and srcDark) provided */}
{hasThemeVariants ? (
<>
<NextImage
className={`${sharedClasses} dark:hidden`}
{...rest}
alt={alt}
src={IMAGE_BASE_URL + srcLight}
/>
<NextImage
className={`${sharedClasses} hidden dark:block`}
{...rest}
alt={alt}
src={IMAGE_BASE_URL + srcDark}
/>
</>
) : (
/* Only src provided - show in both themes */
<NextImage
className={sharedClasses}
{...rest}
alt={alt}
src={IMAGE_BASE_URL + src}
/>
)}
{caption ? <figcaption>{caption}</figcaption> : null}
</figure>
)
},
Check: ({ size }: { size: number }) => (
<span className="inline-flex align-middle text-green-600">&#10003;</span>
),
Cross: ({ size }: { size: number }): ReactNode => (
<span className="inline-flex align-middle text-gray-900">&#10060;</span>
),
}
// use this function to get MDX components, you will need it for rendering MDX
export function getMDXComponents(
components?: MDXComponents,
opts?: { isApp?: boolean; isPages?: boolean }
): MDXComponents {
const isApp = opts?.isApp
const isPages = opts?.isPages
return {
...defaultMdxComponents,
...genericComponents,
AppOnly: ({ children }: { children: ReactNode }): ReactNode =>
isApp ? children : null,
PagesOnly: ({ children }: { children: ReactNode }): ReactNode =>
isPages ? children : null,
...components,
}
}

10
apps/docs/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from 'next'
import { createMDX } from 'fumadocs-mdx/next'
const withMDX = createMDX()
const config: NextConfig = {
reactStrictMode: true,
}
export default withMDX(config)

31
apps/docs/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "next-docs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev-docs": "next dev --turbo",
"build-docs": "next build",
"start": "next start",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"fumadocs-core": "15.7.12",
"fumadocs-mdx": "11.10.0",
"fumadocs-ui": "15.7.12",
"next": "15.5.8",
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "4.1.13",
"@types/mdx": "2.0.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "9.37.0",
"eslint-config-next": "canary",
"tailwindcss": "4.1.13",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,22 @@
import {
defineConfig,
defineDocs,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config'
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.vercel.app/docs/mdx/collections#define-docs
export const docs = defineDocs({
docs: {
schema: frontmatterSchema,
},
meta: {
schema: metaSchema,
},
dir: '../../docs',
})
export default defineConfig({
mdxOptions: {},
})

28
apps/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/.source": ["./.source/index.ts"],
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}