diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eeddd5..0113030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,13 @@ jobs: - name: Test run: pnpm test:once + + - name: Verify stories + working-directory: packages/ui + run: | + npx tsx scripts/check-story-coverage.ts + npx tsx scripts/verify-stories.ts + + - name: Build Storybook + working-directory: packages/ui + run: pnpm build-storybook diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml new file mode 100644 index 0000000..3dbcd95 --- /dev/null +++ b/.github/workflows/storybook.yml @@ -0,0 +1,154 @@ +name: Storybook + +on: + pull_request: + branches: [main] + paths: + - "packages/ui/src/**" + - "packages/ui/.storybook/**" + - "packages/ui/scripts/**" + - "packages/ui/styles.css" + - "packages/ui/themes/**" + - ".github/workflows/storybook.yml" + push: + branches: [main] + paths: + - "packages/ui/src/**" + - "packages/ui/.storybook/**" + +concurrency: + group: storybook-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + verify-stories: + name: Verify Stories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Check story coverage + working-directory: packages/ui + run: pnpm exec tsx scripts/check-story-coverage.ts + + - name: Verify story props + working-directory: packages/ui + run: pnpm exec tsx scripts/verify-stories.ts + + build-storybook: + name: Build Storybook + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build Storybook + working-directory: packages/ui + run: pnpm build-storybook + + - name: Upload Storybook artifact + uses: actions/upload-artifact@v4 + with: + name: storybook-static + path: packages/ui/storybook-static + retention-days: 7 + + test-storybook: + name: Storybook Tests + needs: build-storybook + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm -F @vllnt/ui exec playwright install --with-deps chromium + + - name: Download Storybook artifact + uses: actions/download-artifact@v4 + with: + name: storybook-static + path: packages/ui/storybook-static + + - name: Install http-server + run: npm install -g http-server@14.1.1 + + - name: Run Storybook test-runner + working-directory: packages/ui + run: | + http-server storybook-static --port 6006 --silent & + SERVER_PID=$! + for i in $(seq 1 30); do + if curl -s http://127.0.0.1:6006 > /dev/null 2>&1; then + break + fi + sleep 1 + done + pnpm test-storybook --url http://127.0.0.1:6006 --maxWorkers=2 + kill $SERVER_PID 2>/dev/null || true + + visual-regression: + name: Visual Regression + needs: build-storybook + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Install Playwright browsers + run: pnpm -F @vllnt/ui exec playwright install --with-deps chromium + + - name: Run Playwright CT visual tests + working-directory: packages/ui + run: pnpm test:visual --update-snapshots + + - name: Upload visual snapshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-snapshots + path: packages/ui/.snapshots + retention-days: 30 + + - name: Upload visual test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: visual-test-results + path: | + packages/ui/playwright-report + packages/ui/test-results + retention-days: 7 diff --git a/.gitignore b/.gitignore index a88ea45..afad715 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,13 @@ Thumbs.db # Playwright playwright-results/ playwright-report/ -.snapshots/ +test-results/ + +# Visual test snapshots (platform-specific, generated in CI) +packages/ui/.snapshots/ + +# Storybook +storybook-static/ # Generated public/r/ diff --git a/apps/registry/app/components/[slug]/page.tsx b/apps/registry/app/components/[slug]/page.tsx index 43ff41b..708a1df 100644 --- a/apps/registry/app/components/[slug]/page.tsx +++ b/apps/registry/app/components/[slug]/page.tsx @@ -1,14 +1,15 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { CodeBlock, MDXContent, Sidebar, TableOfContents } from "@vllnt/ui"; +import { CodeBlock, Sidebar, TableOfContents } from "@vllnt/ui"; import { ExternalLink } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { ComponentPreview } from "@/components/component-preview"; import { QuickAdd } from "@/components/quick-add"; +import { StorybookEmbed } from "@/components/storybook-embed"; +import componentMetadata from "@/lib/component-metadata.json"; import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og"; import { getCategoryForComponent, @@ -22,6 +23,20 @@ type Props = { }; const registry = registryData as Registry; +const metadata_map = componentMetadata as Record< + string, + { + category: string; + defaultStoryId: string; + description: string; + name: string; + stories: { id: string; name: string }[]; + title: string; + } +>; + +const STORYBOOK_URL = + process.env.NEXT_PUBLIC_STORYBOOK_URL ?? "http://localhost:6006"; export async function generateStaticParams() { return registry.items @@ -37,44 +52,6 @@ function getNpmUrl(packageName: string): string { return `https://www.npmjs.com/package/${packageName}`; } -function parseFrontmatter(content: string): { - content: string; - metadata: Record; -} { - const frontmatterRegex = /^---\s*\n([\S\s]*?)\n---\s*\n([\S\s]*)$/; - const match = frontmatterRegex.exec(content); - - if (!match?.[1] || !match[2]) { - return { content, metadata: {} }; - } - - const yamlContent = match[1]; - const bodyContent = match[2]; - - const metadata: Record = {}; - yamlContent.split("\n").forEach((line) => { - const colonIndex = line.indexOf(":"); - if (colonIndex > 0) { - const key = line.slice(0, colonIndex).trim(); - const value = line - .slice(colonIndex + 1) - .trim() - .replaceAll(/^["']|["']$/g, ""); - metadata[key] = value; - } - }); - - return { content: bodyContent, metadata }; -} - -function getComponentDirectory(component: RegistryComponent): string { - if (component.files[0]?.path) { - const componentFilePath = path.join(process.cwd(), component.files[0].path); - return path.dirname(componentFilePath); - } - return path.join(process.cwd(), "registry", "default", component.name); -} - export async function generateMetadata(props: Props): Promise { const { slug } = await props.params; const component = registry.items.find( @@ -86,33 +63,22 @@ export async function generateMetadata(props: Props): Promise { return {}; } - const componentDirectory = getComponentDirectory(component); - const headerPath = path.join(componentDirectory, "header.mdx"); + const meta = metadata_map[slug]; const category = getCategoryForComponent(slug); - - let title = `${component.title} - VLLNT UI`; - let description = component.description; - - try { - const headerContent = await readFile(headerPath, "utf8"); - const { metadata } = parseFrontmatter(headerContent); - title = metadata.title || title; - description = metadata.description || description; - } catch { - // header.mdx is optional — use registry.json defaults - } + const title = meta?.title ?? component.title; + const description = meta?.description ?? component.description; const ogParameters = { category, description, - title: component.title, + title, type: "component" as const, }; return { description, openGraph: generateOGMetadata(ogParameters), - title, + title: `${title} - VLLNT UI`, twitter: generateTwitterMetadata(ogParameters), }; } @@ -128,48 +94,30 @@ export default async function ComponentPage(props: Props) { notFound(); } - // Read component file for code display - use actual source from @vllnt/ui + const meta = metadata_map[slug]; + const displayTitle = meta?.title ?? component.title ?? component.name; + const displayDescription = meta?.description ?? component.description ?? ""; + + // Read component source for code display let componentCode = ""; try { - // Map component name to actual source file path in packages/ui/src/components - // Chart components are in chart/ subdirectory: packages/ui/src/components/chart/{name}.tsx const isChartComponent = ["area-chart", "bar-chart", "line-chart"].includes( component.name, ); - if (isChartComponent) { - const chartPath = path.join( - process.cwd(), - "..", - "..", - "packages", - "ui", - "src", - "components", - "chart", - `${component.name}.tsx`, - ); - componentCode = await readFile(chartPath, "utf8"); - } else { - // Most components are in subdirectories: packages/ui/src/components/{name}/{name}.tsx - const subdirectoryPath = path.join( - process.cwd(), - "..", - "..", - "packages", - "ui", - "src", - "components", - component.name, - `${component.name}.tsx`, - ); - - // Try reading from subdirectory first (most components) - try { - componentCode = await readFile(subdirectoryPath, "utf8"); - } catch { - // Fallback to direct file (e.g., theme-provider.tsx) - const directPath = path.join( + const sourcePath = isChartComponent + ? path.join( + process.cwd(), + "..", + "..", + "packages", + "ui", + "src", + "components", + "chart", + `${component.name}.tsx`, + ) + : path.join( process.cwd(), "..", "..", @@ -177,54 +125,40 @@ export default async function ComponentPage(props: Props) { "ui", "src", "components", + component.name, `${component.name}.tsx`, ); - componentCode = await readFile(directPath, "utf8"); - } - } - } catch (error) { - console.error("Error reading component source file:", error); - } - - const componentDirectory = getComponentDirectory(component); - // Read header.mdx for title, description, and SEO metadata - let headerContent = ""; - let headerMetadata: Record = {}; - try { - const headerPath = path.join(componentDirectory, "header.mdx"); - const headerRaw = await readFile(headerPath, "utf8"); - const parsed = parseFrontmatter(headerRaw); - headerContent = parsed.content; - headerMetadata = parsed.metadata; - } catch { - // Header file is optional, fallback to registry defaults - } - - // Read example.mdx for usage examples - let exampleContent = ""; - try { - const examplePath = path.join(componentDirectory, "example.mdx"); - exampleContent = await readFile(examplePath, "utf8"); + try { + componentCode = await readFile(sourcePath, "utf8"); + } catch { + const directPath = path.join( + process.cwd(), + "..", + "..", + "packages", + "ui", + "src", + "components", + `${component.name}.tsx`, + ); + componentCode = await readFile(directPath, "utf8"); + } } catch { - // Example file is optional, ignore if it doesn't exist + // Source file not found — skip code section } const installCommand = `pnpm dlx shadcn@latest add https://ui.vllnt.com/r/${component.name}.json`; const sections = [ { id: "installation", title: "Installation" }, - ...(exampleContent ? [{ id: "usage", title: "Usage" }] : []), + ...(meta?.defaultStoryId ? [{ id: "storybook", title: "Storybook" }] : []), ...(componentCode ? [{ id: "code", title: "Code" }] : []), ...(component.dependencies && component.dependencies.length > 0 ? [{ id: "dependencies", title: "Dependencies" }] : []), ] as { id: string; title: string }[]; - const displayTitle = headerMetadata.title || component.title; - const displayDescription = - headerContent || headerMetadata.description || component.description; - return ( <> @@ -235,26 +169,23 @@ export default async function ComponentPage(props: Props) { {/* Header */}

{displayTitle}

- {headerContent ? ( -
-
- -
-
- ) : ( -

- {displayDescription} -

- )} +

+ {displayDescription} +

- {/* Preview Section */} -
-
- + {/* Preview — Storybook Embed */} + {meta?.defaultStoryId ? ( +
+
+ +
-
+ ) : null} {/* Installation */}
@@ -264,13 +195,43 @@ export default async function ComponentPage(props: Props) {
- {/* Examples */} - {exampleContent ? ( -
-

Usage

-
- -
+ {/* Storybook link */} + {meta?.defaultStoryId ? ( +
+

Storybook

+

+ Explore all variants, controls, and accessibility checks in + the interactive Storybook playground. +

+ + View in Storybook + + + {meta.stories.length > 1 ? ( +
+

+ {meta.stories.length} stories available: +

+
+ {meta.stories.map((story) => ( + + {story.name} + + ))} +
+
+ ) : null}
) : null} diff --git a/apps/registry/app/components/page.tsx b/apps/registry/app/components/page.tsx index e4d9023..02fdea3 100644 --- a/apps/registry/app/components/page.tsx +++ b/apps/registry/app/components/page.tsx @@ -2,6 +2,7 @@ import { Sidebar } from "@vllnt/ui"; import type { Metadata } from "next"; import Link from "next/link"; +import componentMetadata from "@/lib/component-metadata.json"; import { ComponentPreview } from "@/components/component-preview/component-preview"; import { getPageContent } from "@/lib/content"; import { generateOGMetadata, generateTwitterMetadata } from "@/lib/og"; @@ -11,6 +12,15 @@ import { groupedComponents, } from "@/lib/sidebar-sections"; +const metadata_map = componentMetadata as Record< + string, + { + description: string; + stories: { id: string; name: string }[]; + title: string; + } +>; + export async function generateMetadata(): Promise { const { frontmatter } = await getPageContent("components"); const og = frontmatter.og; @@ -49,29 +59,36 @@ export default function ComponentsPage() {

{group.label}

- {group.items.map((component) => ( -
+ {group.items.map((component) => { + const meta = metadata_map[component.name]; + const storyCount = meta?.stories?.length ?? 0; + + return ( - {component.title} - -
-
- +
+

+ {component.title} +

+ {meta?.description ? ( +

+ {meta.description} +

+ ) : null}
-
-
-

- {component.title} -

-
-
- ))} +
+ + {storyCount > 0 + ? `${storyCount} ${storyCount === 1 ? "story" : "stories"}` + : "No preview"} + +
+ + ); + })}
))} diff --git a/apps/registry/components/component-preview/index.ts b/apps/registry/components/component-preview/index.ts deleted file mode 100644 index 7baaf81..0000000 --- a/apps/registry/components/component-preview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ComponentPreview } from "./component-preview"; diff --git a/apps/registry/components/storybook-embed/index.ts b/apps/registry/components/storybook-embed/index.ts new file mode 100644 index 0000000..7a74ad5 --- /dev/null +++ b/apps/registry/components/storybook-embed/index.ts @@ -0,0 +1 @@ +export { StorybookEmbed } from "./storybook-embed"; diff --git a/apps/registry/components/storybook-embed/storybook-embed.tsx b/apps/registry/components/storybook-embed/storybook-embed.tsx new file mode 100644 index 0000000..aa5ebf3 --- /dev/null +++ b/apps/registry/components/storybook-embed/storybook-embed.tsx @@ -0,0 +1,68 @@ +"use client"; + +import * as React from "react"; + +const STORYBOOK_URL = + process.env.NEXT_PUBLIC_STORYBOOK_URL ?? "http://localhost:6006"; + +function toStoryId(componentName: string): string { + return `components-${componentName}--default`; +} + +type StorybookEmbedProps = { + className?: string; + componentName: string; + height?: number; + storyId?: string; +}; + +export function StorybookEmbed({ + className, + componentName, + height = 400, + storyId, +}: StorybookEmbedProps): React.ReactElement { + const [isLoaded, setIsLoaded] = React.useState(false); + const [iframeSource, setIframeSource] = React.useState(""); + const resolvedStoryId = encodeURIComponent( + storyId ?? toStoryId(componentName), + ); + + React.useEffect(() => { + setIframeSource( + `${STORYBOOK_URL}/iframe.html?id=${resolvedStoryId}&viewMode=story&shortcuts=false&singleStory=true`, + ); + }, [resolvedStoryId]); + + return ( +
+ {isLoaded ? null : ( +
+

Loading preview...

+
+ )} + {iframeSource ? ( +