diff --git a/apps/docs/.pi/briefs/shieldcn.md b/apps/docs/.pi/briefs/shieldcn.md new file mode 100644 index 0000000..1e72640 --- /dev/null +++ b/apps/docs/.pi/briefs/shieldcn.md @@ -0,0 +1,523 @@ +# shieldcn + +Beautiful README badges as a service. A shields.io alternative with the visual quality of shadcn/ui components. + +## What this is + +A standalone Next.js app that serves styled SVG badge images for use in GitHub READMEs, npm pages, docs sites, and anywhere that accepts `` tags. Own repo, own domain, own brand. + +Not a jalco ui subpage. A separate product that happens to share the same design language and links back to jalco ui as the component library behind it. + +## Why this should be separate + +- Shields.io is a product, not a feature. This competes with it, so it should feel like a product. +- Own domain means own SEO. "shieldcn.dev" ranks on its own. +- Own repo means contributors can work on badge coverage without touching the component library. +- Own deploy means badge uptime is independent of docs site deploys. +- Marketing surface: every badge image served is a backlink. Every README is an ad. +- Can grow independently — more providers, a badge builder, an API, a GitHub App — without bloating jalco ui. + +## Domain + +`shieldcn.dev` + +## Scaffolding + +### Stack (mirror jalco ui) + +| Concern | Tool | Notes | +|---------|------|-------| +| Framework | Next.js 16 | Same version as jalco ui | +| Docs | Fumadocs (fumadocs-core, fumadocs-mdx, @fumadocs/base-ui) | Same MDX-driven docs setup | +| Styling | Tailwind CSS v4 | CSS-first `@theme` config | +| Components | shadcn/ui primitives | Only what the landing/docs pages need | +| Fonts | Geist + Geist Mono | Same as jalco ui | +| Package manager | pnpm | With workspace if needed later | +| Linting | ESLint + lint-staged + Husky | Same hooks as jalco ui | +| Deploy | Vercel | Edge caching for badge responses | + +### Init commands + +```bash +# Create repo +mkdir shieldcn && cd shieldcn +git init + +# Scaffold Next.js +pnpm create next-app@latest . --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*" + +# Add Fumadocs +pnpm add fumadocs-core fumadocs-mdx +pnpm add -D @fumadocs/base-ui + +# Add shadcn +pnpm dlx shadcn@latest init + +# Add shared deps +pnpm add class-variance-authority clsx tailwind-merge + +# Add dev tooling +pnpm add -D husky lint-staged tsx +``` + +### Repo structure + +``` +shieldcn/ +├── app/ +│ ├── layout.tsx ← root layout (Geist fonts, theme provider, metadata) +│ ├── page.tsx ← landing page: hero, badge builder, comparison, examples +│ ├── globals.css ← Tailwind v4 @theme config +│ ├── [...slug]/ +│ │ └── route.ts ← catch-all badge SVG route handler +│ └── docs/ +│ ├── layout.tsx ← Fumadocs docs layout +│ ├── page.tsx ← docs index +│ └── [...slug]/ +│ └── page.tsx ← MDX docs pages +├── content/ +│ └── docs/ +│ ├── index.mdx ← getting started +│ ├── meta.json ← Fumadocs page tree +│ ├── badges/ +│ │ ├── meta.json +│ │ ├── npm.mdx ← npm badge docs +│ │ ├── github-stars.mdx +│ │ ├── github-release.mdx +│ │ ├── github-ci.mdx +│ │ ├── github-license.mdx +│ │ └── discord.mdx +│ ├── customization/ +│ │ ├── meta.json +│ │ ├── themes.mdx +│ │ └── styles.mdx +│ └── api-reference.mdx +├── lib/ +│ ├── badges/ +│ │ ├── render.ts ← SVG rendering engine +│ │ ├── measure.ts ← text width calculation (char width lookup) +│ │ ├── themes.ts ← shadcn color palettes → resolved hex values +│ │ ├── icons.ts ← provider SVG icon path data +│ │ └── types.ts ← shared types +│ ├── providers/ +│ │ ├── npm.ts ← npm registry + downloads API +│ │ ├── github.ts ← GitHub repos, releases, actions, license API +│ │ └── discord.ts ← Discord widget API +│ └── utils.ts ← cn(), formatCount(), etc. +├── components/ +│ ├── badge-builder.tsx ← interactive badge builder (client component) +│ ├── badge-preview.tsx ← live badge preview with copy button +│ ├── theme-provider.tsx +│ └── ui/ ← shadcn primitives (button, input, select, etc.) +├── source.config.ts ← Fumadocs MDX config +├── next.config.ts ← withMDX wrapper +├── package.json +├── tsconfig.json +├── .gitignore +├── .husky/ +│ └── pre-commit +├── AGENTS.md +├── README.md ← uses its own badges +└── LICENSE +``` + +### Fumadocs setup + +`source.config.ts`: +```ts +import { defineDocs, defineConfig } from "fumadocs-mdx/config" + +export const docs = defineDocs({ + dir: "content/docs", +}) + +export default defineConfig({ + mdxOptions: {}, +}) +``` + +`next.config.ts`: +```ts +import type { NextConfig } from "next" +import { createMDX } from "fumadocs-mdx/next" + +const nextConfig: NextConfig = {} +const withMDX = createMDX() +export default withMDX(nextConfig) +``` + +`app/docs/layout.tsx`: +```tsx +import { DocsLayout } from "fumadocs-ui/layouts/docs" +import type { ReactNode } from "react" + +export default function Layout({ children }: { children: ReactNode }) { + return {children} +} +``` + +### Tailwind v4 config + +`globals.css`: +```css +@import "tailwindcss"; + +@theme { + --color-background: oklch(100% 0 0); + --color-foreground: oklch(14.5% 0.025 264); + --color-muted: oklch(96% 0.01 264); + --color-muted-foreground: oklch(46% 0.02 264); + --color-border: oklch(91% 0.01 264); + /* ... standard shadcn tokens */ +} +``` + +## URL format + +``` +https://shieldcn.dev/{provider}/{...params}.svg → SVG badge image +https://shieldcn.dev/{provider}/{...params}.json → raw badge data +https://shieldcn.dev/{provider}/{...params}/shields.json → shields.io compatible +``` + +### v1 badge types + +| Badge | URL | Data source | +|-------|-----|-------------| +| npm version | `/{package}.svg` | registry.npmjs.org | +| npm downloads | `/npm/{package}/downloads.svg` | api.npmjs.org | +| GitHub release | `/github/{owner}/{repo}/release.svg` | GitHub Releases API | +| GitHub stars | `/github/{owner}/{repo}/stars.svg` | GitHub Repos API | +| CI status | `/github/{owner}/{repo}/ci.svg` | GitHub Actions API | +| License | `/github/{owner}/{repo}/license.svg` | GitHub Repos API | +| Discord online | `/discord/{serverId}.svg` | Discord widget API | + +### Query params + +| Param | Type | Description | +|-------|------|-------------| +| `style` | `default` \| `outline` \| `subtle` \| `flat` | Visual style. Default: `default` | +| `theme` | `zinc` \| `slate` \| `stone` \| `neutral` \| `blue` \| `green` \| `rose` \| ... | Color theme using shadcn palette names. Default: `zinc` | +| `color` | hex string (no `#`) | Override badge accent color. Overrides theme. | +| `labelColor` | hex string (no `#`) | Override label background color. | +| `label` | string | Override the left-side label text | +| `logo` | `true` \| `false` | Show/hide the provider icon. Default: `true` | + +### Usage in markdown + +```md +![npm version](https://shieldcn.dev/npm/react.svg) +![stars](https://shieldcn.dev/github/vercel/next.js/stars.svg?theme=blue) +![CI](https://shieldcn.dev/github/jal-co/ui/ci.svg?style=outline) +![Discord](https://shieldcn.dev/discord/1316199667142496307.svg) +``` + +## SVG rendering engine + +### Core function + +```ts +interface BadgeConfig { + label: string // left side text ("npm", "release", "CI") + value: string // right side text ("v19.1.0", "45.2k", "passing") + icon?: string // SVG path data for provider icon + iconViewBox?: string // viewBox for the icon + style: "default" | "outline" | "subtle" | "flat" + colors: ResolvedColors // resolved hex values + statusColor?: string // override value bg for CI status (green/red/amber) +} + +function renderBadge(config: BadgeConfig): string +``` + +Returns a complete SVG string. No React, no Tailwind, no external dependencies. + +### Layout math + +``` +┌─────────────────────────────────────────┐ +│ [icon] label │ value │ +└─────────────────────────────────────────┘ + ↑ ↑ ↑ + iconWidth │ valueWidth + + padding │ + padding + divider +``` + +1. Measure label text width using character lookup table +2. Measure value text width +3. Add icon width + padding if icon enabled +4. Calculate total badge width +5. Position all elements with x/y coordinates + +Badge height: 20px — standard README badge height, aligns with shields.io badges. + +### Text measurement + +Lookup table approach (same technique as shields.io). Pre-measured character widths for the badge font at 11px. No native dependencies. + +```ts +const CHAR_WIDTHS: Record = { + "a": 6.2, "b": 6.8, "c": 5.6, // ... + "0": 6.6, "1": 4.4, // ... +} + +function measureText(text: string, fontSize: number): number +``` + +### Theme system + +Map shadcn palette names to resolved hex values: + +```ts +interface ResolvedColors { + labelBg: string + labelFg: string + valueBg: string + valueFg: string + border: string +} + +const themes: Record = { + zinc: { labelBg: "#27272a", labelFg: "#fafafa", valueBg: "#3f3f46", valueFg: "#fafafa", border: "#52525b" }, + slate: { labelBg: "#1e293b", labelFg: "#f8fafc", valueBg: "#334155", valueFg: "#f8fafc", border: "#475569" }, + blue: { labelBg: "#1e3a5f", labelFg: "#dbeafe", valueBg: "#2563eb", valueFg: "#ffffff", border: "#3b82f6" }, + green: { labelBg: "#14532d", labelFg: "#dcfce7", valueBg: "#16a34a", valueFg: "#ffffff", border: "#22c55e" }, + rose: { labelBg: "#4c0519", labelFg: "#ffe4e6", valueBg: "#e11d48", valueFg: "#ffffff", border: "#f43f5e" }, + // ... +} +``` + +Default: `zinc`. + +Tailwind CSS tokens can't be used directly in SVG images. SVGs served as `` are sandboxed — no external CSS, no CSS variables, no class-based styling. The theme system uses the same vocabulary as shadcn (palette names like `zinc`, `slate`, `blue`) but resolves them to hex values at render time. + +### Style variants + +- **default** — dark label bg, slightly lighter value bg, subtle border, rounded corners +- **outline** — transparent bg, visible border, themed text colors +- **subtle** — fully rounded pill, muted background, no strong contrast between label/value +- **flat** — shields.io compatible flat style for people who want consistency with existing badges + +### Icons + +Raw SVG path data for each provider: + +```ts +const icons: Record = { + npm: { viewBox: "0 0 256 256", path: "M0 256V0h256..." }, + github: { viewBox: "0 0 16 16", path: "M8 0C3.58 0..." }, + discord: { viewBox: "0 -28.5 256 256", path: "M216.856339...", fillRule: "nonzero" }, +} +``` + +## Data providers + +Port the fetch functions from jalco ui's registry libs. Simplify — each provider returns `{ label, value, statusColor? }`. + +```ts +// lib/providers/npm.ts +export async function getNpmVersion(pkg: string): Promise +export async function getNpmDownloads(pkg: string): Promise + +// lib/providers/github.ts +export async function getGitHubStars(owner: string, repo: string): Promise +export async function getGitHubRelease(owner: string, repo: string): Promise +export async function getGitHubCI(owner: string, repo: string): Promise +export async function getGitHubLicense(owner: string, repo: string): Promise + +// lib/providers/discord.ts +export async function getDiscordOnline(serverId: string): Promise +``` + +## API + +The badge service is the API. Every badge URL is a public endpoint that returns data in the requested format. + +### SVG endpoint (default) + +``` +GET /npm/react.svg → image/svg+xml +GET /github/vercel/next.js/stars.svg → image/svg+xml +``` + +Returns a rendered SVG badge image. This is what `` tags and markdown `![]()` reference. + +### JSON endpoint + +Append `.json` instead of `.svg` to get raw badge data: + +``` +GET /npm/react.json → application/json +GET /github/vercel/next.js/stars.json → application/json +``` + +Returns: +```json +{ + "label": "npm", + "value": "v19.1.0", + "color": "blue", + "link": "https://www.npmjs.com/package/react" +} +``` + +This lets people build their own badge rendering or consume the data for dashboards, scripts, or CI. Same cache headers as SVG. + +### Shields.io endpoint compatibility + +Serve a shields.io-compatible JSON endpoint so people can use shieldcn as a data source with shields.io rendering if they want: + +``` +GET /npm/react/shields.json +``` + +Returns: +```json +{ + "schemaVersion": 1, + "label": "npm", + "message": "v19.1.0", + "color": "blue" +} +``` + +This works with shields.io's [endpoint badge](https://shields.io/badges/endpoint-badge): +```md +![npm](https://img.shields.io/endpoint?url=https://shieldcn.dev/npm/react/shields.json) +``` + +So people can migrate gradually — use shieldcn data with shields.io rendering first, then switch to shieldcn rendering when ready. + +### Route handler + +`app/[...slug]/route.ts` — single catch-all: + +```ts +export async function GET( + request: Request, + { params }: { params: { slug: string[] } } +) { + const segments = params.slug + const lastSegment = segments[segments.length - 1] + + // Determine response format from file extension + const format = lastSegment.endsWith(".json") + ? lastSegment.endsWith("/shields.json") ? "shields" : "json" + : "svg" + + // Strip extension from last segment for parsing + // Parse remaining segments → provider + badge type + resource params + // Parse searchParams → style, theme, color, label, logo + // Validate inputs + // Fetch data from provider API + // Return SVG, JSON, or shields-compatible JSON based on format +} +``` + +## Caching + +```ts +return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400", + }, +}) +``` + +GitHub's Camo image proxy caches for ~4 hours on top of this. Combined with Vercel edge caching, origin hits will be minimal. + +## Error handling + +On bad params or failed API calls, return a valid SVG badge showing the error: + +``` +┌──────────────────────────┐ +│ error │ package not found │ +└──────────────────────────┘ +``` + +Never return a broken image. Always return a valid SVG. + +## Landing page + +Simple, one-page site at `/`: +- Hero: "Beautiful README badges" + a row of example badges rendered live as `` tags +- Badge builder: provider selector, package/repo input, style/theme dropdowns, live preview, copy-to-clipboard markdown +- Comparison section: same badge in shields.io vs shieldcn side by side +- URL reference: quick table of all badge types + params +- Footer: "Built with jal-co/ui" link + +The badge builder is a client component. Everything else is static. + +## Docs pages (Fumadocs) + +Structured under `/docs`: +- Getting started (install in your README) +- Per-badge-type pages (npm, GitHub stars, release, CI, license, Discord) +- Customization: themes, styles +- API reference: full URL + param spec + +Each badge doc page shows: +- Live badge preview (as `` tags) +- Markdown snippet to copy +- All relevant query params with examples +- Visual comparison of style/theme variants + +## Cross-linking with jalco ui + +- shieldcn footer: "Badge components for React → jal-co/ui" +- jalco ui docs: "Use these badges in your README → shieldcn.dev" +- jalco ui npm-badge, release-badge, ci-badge, discord-badge docs: add a "README badge" section with the shieldcn URL equivalent +- jalco ui README: use shieldcn badges + +## Implementation order + +1. Scaffold Next.js app with Fumadocs, Tailwind v4, Geist fonts +2. `lib/badges/measure.ts` — character width lookup table +3. `lib/badges/themes.ts` — shadcn color palettes +4. `lib/badges/icons.ts` — provider SVG paths (copy from jalco ui) +5. `lib/badges/render.ts` — core SVG renderer +6. Test renderer with hardcoded data — get the visual quality right before adding API calls +7. `lib/providers/` — port fetch functions from jalco ui +8. `app/[...slug]/route.ts` — route handler +9. Test all 7 badge types with real data in GitHub README +10. Landing page with hero, comparison, badge builder +11. Fumadocs docs pages +12. README using its own badges +13. Deploy to Vercel, connect domain +14. Add cross-links from jalco ui + +## What NOT to build in v1 + +- Services beyond the 7 listed +- Dark mode auto-detection (GitHub doesn't support it in README SVGs) +- Custom fonts +- Auth, rate limiting, user accounts +- GitHub App +- Separate analytics (use Vercel analytics or umami) +- GraphQL or complex query API — the URL *is* the API, keep it REST/resource-based + +## Success criteria + +- Badges render correctly in GitHub READMEs +- Badges render correctly on npm package pages +- Visual quality is noticeably better than shields.io +- All 7 badge types work with live data +- Response times < 200ms (cached) +- Badge URLs are intuitive +- Landing page badge builder copies markdown to clipboard +- Docs cover every badge type and customization option +- jalco ui's own README uses shieldcn badges + +## Future (v2+) + +- More providers: PyPI, Crates.io, Packagist, Docker Hub, Codecov, Bundlephobia +- Custom/static badges: `/badge/{label}/{value}.svg?color=blue` +- Sparkline badges: tiny inline SVG charts as badge images +- GitHub App: auto-generates badge markdown for repos +- `?mode=dark` for dark-background READMEs +- Badge gallery page +- Contributor-friendly provider plugin system +- OpenAPI spec diff --git a/apps/docs/.source/browser.ts b/apps/docs/.source/browser.ts index 12992cd..490e939 100644 --- a/apps/docs/.source/browser.ts +++ b/apps/docs/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "components/activity-graph.mdx": () => import("../content/docs/components/activity-graph.mdx?collection=docs"), "components/ai-copy-button.mdx": () => import("../content/docs/components/ai-copy-button.mdx?collection=docs"), "components/api-ref-table.mdx": () => import("../content/docs/components/api-ref-table.mdx?collection=docs"), "components/balanced-text.mdx": () => import("../content/docs/components/balanced-text.mdx?collection=docs"), "components/chat-bubble.mdx": () => import("../content/docs/components/chat-bubble.mdx?collection=docs"), "components/code-block-command.mdx": () => import("../content/docs/components/code-block-command.mdx?collection=docs"), "components/code-block.mdx": () => import("../content/docs/components/code-block.mdx?collection=docs"), "components/code-line.mdx": () => import("../content/docs/components/code-line.mdx?collection=docs"), "components/color-palette.mdx": () => import("../content/docs/components/color-palette.mdx?collection=docs"), "components/commit-graph.mdx": () => import("../content/docs/components/commit-graph.mdx?collection=docs"), "components/contributor-grid.mdx": () => import("../content/docs/components/contributor-grid.mdx?collection=docs"), "components/cron-schedule.mdx": () => import("../content/docs/components/cron-schedule.mdx?collection=docs"), "components/diff-viewer.mdx": () => import("../content/docs/components/diff-viewer.mdx?collection=docs"), "components/env-table.mdx": () => import("../content/docs/components/env-table.mdx?collection=docs"), "components/file-tree.mdx": () => import("../content/docs/components/file-tree.mdx?collection=docs"), "components/github-button-group.mdx": () => import("../content/docs/components/github-button-group.mdx?collection=docs"), "components/github-stars-button.mdx": () => import("../content/docs/components/github-stars-button.mdx?collection=docs"), "components/json-viewer.mdx": () => import("../content/docs/components/json-viewer.mdx?collection=docs"), "components/kbd.mdx": () => import("../content/docs/components/kbd.mdx?collection=docs"), "components/license-badge.mdx": () => import("../content/docs/components/license-badge.mdx?collection=docs"), "components/log-viewer.mdx": () => import("../content/docs/components/log-viewer.mdx?collection=docs"), "components/logo-cloud.mdx": () => import("../content/docs/components/logo-cloud.mdx?collection=docs"), "components/masonry-grid.mdx": () => import("../content/docs/components/masonry-grid.mdx?collection=docs"), "components/npm-badge.mdx": () => import("../content/docs/components/npm-badge.mdx?collection=docs"), "components/pretext.mdx": () => import("../content/docs/components/pretext.mdx?collection=docs"), "components/producthunt-button.mdx": () => import("../content/docs/components/producthunt-button.mdx?collection=docs"), "components/repo-card.mdx": () => import("../content/docs/components/repo-card.mdx?collection=docs"), "components/request-viewer.mdx": () => import("../content/docs/components/request-viewer.mdx?collection=docs"), "components/status-indicator.mdx": () => import("../content/docs/components/status-indicator.mdx?collection=docs"), "components/stepper.mdx": () => import("../content/docs/components/stepper.mdx?collection=docs"), "components/testimonial.mdx": () => import("../content/docs/components/testimonial.mdx?collection=docs"), "components/tip-jar.mdx": () => import("../content/docs/components/tip-jar.mdx?collection=docs"), }), + docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "components/activity-graph.mdx": () => import("../content/docs/components/activity-graph.mdx?collection=docs"), "components/ai-copy-button.mdx": () => import("../content/docs/components/ai-copy-button.mdx?collection=docs"), "components/api-ref-table.mdx": () => import("../content/docs/components/api-ref-table.mdx?collection=docs"), "components/balanced-text.mdx": () => import("../content/docs/components/balanced-text.mdx?collection=docs"), "components/chat-bubble.mdx": () => import("../content/docs/components/chat-bubble.mdx?collection=docs"), "components/ci-badge.mdx": () => import("../content/docs/components/ci-badge.mdx?collection=docs"), "components/code-block-command.mdx": () => import("../content/docs/components/code-block-command.mdx?collection=docs"), "components/code-block.mdx": () => import("../content/docs/components/code-block.mdx?collection=docs"), "components/code-line.mdx": () => import("../content/docs/components/code-line.mdx?collection=docs"), "components/color-palette.mdx": () => import("../content/docs/components/color-palette.mdx?collection=docs"), "components/commit-graph.mdx": () => import("../content/docs/components/commit-graph.mdx?collection=docs"), "components/contributor-grid.mdx": () => import("../content/docs/components/contributor-grid.mdx?collection=docs"), "components/cron-schedule.mdx": () => import("../content/docs/components/cron-schedule.mdx?collection=docs"), "components/diff-viewer.mdx": () => import("../content/docs/components/diff-viewer.mdx?collection=docs"), "components/discord-badge.mdx": () => import("../content/docs/components/discord-badge.mdx?collection=docs"), "components/download-sparkline.mdx": () => import("../content/docs/components/download-sparkline.mdx?collection=docs"), "components/env-table.mdx": () => import("../content/docs/components/env-table.mdx?collection=docs"), "components/file-tree.mdx": () => import("../content/docs/components/file-tree.mdx?collection=docs"), "components/github-button-group.mdx": () => import("../content/docs/components/github-button-group.mdx?collection=docs"), "components/github-stars-button.mdx": () => import("../content/docs/components/github-stars-button.mdx?collection=docs"), "components/json-viewer.mdx": () => import("../content/docs/components/json-viewer.mdx?collection=docs"), "components/kbd.mdx": () => import("../content/docs/components/kbd.mdx?collection=docs"), "components/license-badge.mdx": () => import("../content/docs/components/license-badge.mdx?collection=docs"), "components/log-viewer.mdx": () => import("../content/docs/components/log-viewer.mdx?collection=docs"), "components/logo-cloud.mdx": () => import("../content/docs/components/logo-cloud.mdx?collection=docs"), "components/masonry-grid.mdx": () => import("../content/docs/components/masonry-grid.mdx?collection=docs"), "components/npm-badge.mdx": () => import("../content/docs/components/npm-badge.mdx?collection=docs"), "components/pretext.mdx": () => import("../content/docs/components/pretext.mdx?collection=docs"), "components/producthunt-button.mdx": () => import("../content/docs/components/producthunt-button.mdx?collection=docs"), "components/release-badge.mdx": () => import("../content/docs/components/release-badge.mdx?collection=docs"), "components/repo-card.mdx": () => import("../content/docs/components/repo-card.mdx?collection=docs"), "components/request-viewer.mdx": () => import("../content/docs/components/request-viewer.mdx?collection=docs"), "components/status-indicator.mdx": () => import("../content/docs/components/status-indicator.mdx?collection=docs"), "components/stepper.mdx": () => import("../content/docs/components/stepper.mdx?collection=docs"), "components/testimonial.mdx": () => import("../content/docs/components/testimonial.mdx?collection=docs"), "components/tip-jar.mdx": () => import("../content/docs/components/tip-jar.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/docs/.source/server.ts b/apps/docs/.source/server.ts index 2fc7446..f414dc1 100644 --- a/apps/docs/.source/server.ts +++ b/apps/docs/.source/server.ts @@ -1,31 +1,35 @@ // @ts-nocheck -import * as __fd_glob_34 from "../content/docs/components/tip-jar.mdx?collection=docs" -import * as __fd_glob_33 from "../content/docs/components/testimonial.mdx?collection=docs" -import * as __fd_glob_32 from "../content/docs/components/stepper.mdx?collection=docs" -import * as __fd_glob_31 from "../content/docs/components/status-indicator.mdx?collection=docs" -import * as __fd_glob_30 from "../content/docs/components/request-viewer.mdx?collection=docs" -import * as __fd_glob_29 from "../content/docs/components/repo-card.mdx?collection=docs" -import * as __fd_glob_28 from "../content/docs/components/producthunt-button.mdx?collection=docs" -import * as __fd_glob_27 from "../content/docs/components/pretext.mdx?collection=docs" -import * as __fd_glob_26 from "../content/docs/components/npm-badge.mdx?collection=docs" -import * as __fd_glob_25 from "../content/docs/components/masonry-grid.mdx?collection=docs" -import * as __fd_glob_24 from "../content/docs/components/logo-cloud.mdx?collection=docs" -import * as __fd_glob_23 from "../content/docs/components/log-viewer.mdx?collection=docs" -import * as __fd_glob_22 from "../content/docs/components/license-badge.mdx?collection=docs" -import * as __fd_glob_21 from "../content/docs/components/kbd.mdx?collection=docs" -import * as __fd_glob_20 from "../content/docs/components/json-viewer.mdx?collection=docs" -import * as __fd_glob_19 from "../content/docs/components/github-stars-button.mdx?collection=docs" -import * as __fd_glob_18 from "../content/docs/components/github-button-group.mdx?collection=docs" -import * as __fd_glob_17 from "../content/docs/components/file-tree.mdx?collection=docs" -import * as __fd_glob_16 from "../content/docs/components/env-table.mdx?collection=docs" -import * as __fd_glob_15 from "../content/docs/components/diff-viewer.mdx?collection=docs" -import * as __fd_glob_14 from "../content/docs/components/cron-schedule.mdx?collection=docs" -import * as __fd_glob_13 from "../content/docs/components/contributor-grid.mdx?collection=docs" -import * as __fd_glob_12 from "../content/docs/components/commit-graph.mdx?collection=docs" -import * as __fd_glob_11 from "../content/docs/components/color-palette.mdx?collection=docs" -import * as __fd_glob_10 from "../content/docs/components/code-line.mdx?collection=docs" -import * as __fd_glob_9 from "../content/docs/components/code-block.mdx?collection=docs" -import * as __fd_glob_8 from "../content/docs/components/code-block-command.mdx?collection=docs" +import * as __fd_glob_38 from "../content/docs/components/tip-jar.mdx?collection=docs" +import * as __fd_glob_37 from "../content/docs/components/testimonial.mdx?collection=docs" +import * as __fd_glob_36 from "../content/docs/components/stepper.mdx?collection=docs" +import * as __fd_glob_35 from "../content/docs/components/status-indicator.mdx?collection=docs" +import * as __fd_glob_34 from "../content/docs/components/request-viewer.mdx?collection=docs" +import * as __fd_glob_33 from "../content/docs/components/repo-card.mdx?collection=docs" +import * as __fd_glob_32 from "../content/docs/components/release-badge.mdx?collection=docs" +import * as __fd_glob_31 from "../content/docs/components/producthunt-button.mdx?collection=docs" +import * as __fd_glob_30 from "../content/docs/components/pretext.mdx?collection=docs" +import * as __fd_glob_29 from "../content/docs/components/npm-badge.mdx?collection=docs" +import * as __fd_glob_28 from "../content/docs/components/masonry-grid.mdx?collection=docs" +import * as __fd_glob_27 from "../content/docs/components/logo-cloud.mdx?collection=docs" +import * as __fd_glob_26 from "../content/docs/components/log-viewer.mdx?collection=docs" +import * as __fd_glob_25 from "../content/docs/components/license-badge.mdx?collection=docs" +import * as __fd_glob_24 from "../content/docs/components/kbd.mdx?collection=docs" +import * as __fd_glob_23 from "../content/docs/components/json-viewer.mdx?collection=docs" +import * as __fd_glob_22 from "../content/docs/components/github-stars-button.mdx?collection=docs" +import * as __fd_glob_21 from "../content/docs/components/github-button-group.mdx?collection=docs" +import * as __fd_glob_20 from "../content/docs/components/file-tree.mdx?collection=docs" +import * as __fd_glob_19 from "../content/docs/components/env-table.mdx?collection=docs" +import * as __fd_glob_18 from "../content/docs/components/download-sparkline.mdx?collection=docs" +import * as __fd_glob_17 from "../content/docs/components/discord-badge.mdx?collection=docs" +import * as __fd_glob_16 from "../content/docs/components/diff-viewer.mdx?collection=docs" +import * as __fd_glob_15 from "../content/docs/components/cron-schedule.mdx?collection=docs" +import * as __fd_glob_14 from "../content/docs/components/contributor-grid.mdx?collection=docs" +import * as __fd_glob_13 from "../content/docs/components/commit-graph.mdx?collection=docs" +import * as __fd_glob_12 from "../content/docs/components/color-palette.mdx?collection=docs" +import * as __fd_glob_11 from "../content/docs/components/code-line.mdx?collection=docs" +import * as __fd_glob_10 from "../content/docs/components/code-block.mdx?collection=docs" +import * as __fd_glob_9 from "../content/docs/components/code-block-command.mdx?collection=docs" +import * as __fd_glob_8 from "../content/docs/components/ci-badge.mdx?collection=docs" import * as __fd_glob_7 from "../content/docs/components/chat-bubble.mdx?collection=docs" import * as __fd_glob_6 from "../content/docs/components/balanced-text.mdx?collection=docs" import * as __fd_glob_5 from "../content/docs/components/api-ref-table.mdx?collection=docs" @@ -42,4 +46,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "components/meta.json": __fd_glob_1, }, {"index.mdx": __fd_glob_2, "components/activity-graph.mdx": __fd_glob_3, "components/ai-copy-button.mdx": __fd_glob_4, "components/api-ref-table.mdx": __fd_glob_5, "components/balanced-text.mdx": __fd_glob_6, "components/chat-bubble.mdx": __fd_glob_7, "components/code-block-command.mdx": __fd_glob_8, "components/code-block.mdx": __fd_glob_9, "components/code-line.mdx": __fd_glob_10, "components/color-palette.mdx": __fd_glob_11, "components/commit-graph.mdx": __fd_glob_12, "components/contributor-grid.mdx": __fd_glob_13, "components/cron-schedule.mdx": __fd_glob_14, "components/diff-viewer.mdx": __fd_glob_15, "components/env-table.mdx": __fd_glob_16, "components/file-tree.mdx": __fd_glob_17, "components/github-button-group.mdx": __fd_glob_18, "components/github-stars-button.mdx": __fd_glob_19, "components/json-viewer.mdx": __fd_glob_20, "components/kbd.mdx": __fd_glob_21, "components/license-badge.mdx": __fd_glob_22, "components/log-viewer.mdx": __fd_glob_23, "components/logo-cloud.mdx": __fd_glob_24, "components/masonry-grid.mdx": __fd_glob_25, "components/npm-badge.mdx": __fd_glob_26, "components/pretext.mdx": __fd_glob_27, "components/producthunt-button.mdx": __fd_glob_28, "components/repo-card.mdx": __fd_glob_29, "components/request-viewer.mdx": __fd_glob_30, "components/status-indicator.mdx": __fd_glob_31, "components/stepper.mdx": __fd_glob_32, "components/testimonial.mdx": __fd_glob_33, "components/tip-jar.mdx": __fd_glob_34, }); \ No newline at end of file +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "components/meta.json": __fd_glob_1, }, {"index.mdx": __fd_glob_2, "components/activity-graph.mdx": __fd_glob_3, "components/ai-copy-button.mdx": __fd_glob_4, "components/api-ref-table.mdx": __fd_glob_5, "components/balanced-text.mdx": __fd_glob_6, "components/chat-bubble.mdx": __fd_glob_7, "components/ci-badge.mdx": __fd_glob_8, "components/code-block-command.mdx": __fd_glob_9, "components/code-block.mdx": __fd_glob_10, "components/code-line.mdx": __fd_glob_11, "components/color-palette.mdx": __fd_glob_12, "components/commit-graph.mdx": __fd_glob_13, "components/contributor-grid.mdx": __fd_glob_14, "components/cron-schedule.mdx": __fd_glob_15, "components/diff-viewer.mdx": __fd_glob_16, "components/discord-badge.mdx": __fd_glob_17, "components/download-sparkline.mdx": __fd_glob_18, "components/env-table.mdx": __fd_glob_19, "components/file-tree.mdx": __fd_glob_20, "components/github-button-group.mdx": __fd_glob_21, "components/github-stars-button.mdx": __fd_glob_22, "components/json-viewer.mdx": __fd_glob_23, "components/kbd.mdx": __fd_glob_24, "components/license-badge.mdx": __fd_glob_25, "components/log-viewer.mdx": __fd_glob_26, "components/logo-cloud.mdx": __fd_glob_27, "components/masonry-grid.mdx": __fd_glob_28, "components/npm-badge.mdx": __fd_glob_29, "components/pretext.mdx": __fd_glob_30, "components/producthunt-button.mdx": __fd_glob_31, "components/release-badge.mdx": __fd_glob_32, "components/repo-card.mdx": __fd_glob_33, "components/request-viewer.mdx": __fd_glob_34, "components/status-indicator.mdx": __fd_glob_35, "components/stepper.mdx": __fd_glob_36, "components/testimonial.mdx": __fd_glob_37, "components/tip-jar.mdx": __fd_glob_38, }); \ No newline at end of file diff --git a/apps/docs/components/docs/footer.tsx b/apps/docs/components/docs/footer.tsx index 4750f1b..850d717 100644 --- a/apps/docs/components/docs/footer.tsx +++ b/apps/docs/components/docs/footer.tsx @@ -1,13 +1,7 @@ import Link from "next/link" import { JalcoLogo } from "@/components/icons/jalco-logo" +import { DiscordBadge } from "@/registry/discord-badge/discord-badge" -function DiscordIcon(props: React.SVGProps) { - return ( - - ) -} function GitHubIcon(props: React.SVGProps) { return ( @@ -100,15 +94,12 @@ export function SiteFooter() {

+ ) +} diff --git a/apps/docs/components/docs/previews/discord-badge.tsx b/apps/docs/components/docs/previews/discord-badge.tsx new file mode 100644 index 0000000..6f0531b --- /dev/null +++ b/apps/docs/components/docs/previews/discord-badge.tsx @@ -0,0 +1,29 @@ +import { DiscordBadge } from "@/registry/discord-badge/discord-badge" +import type { DiscordServerData } from "@/registry/discord-badge/lib/discord" + +const sampleServer: DiscordServerData = { + id: "123456789", + name: "Tailwind CSS", + instantInvite: "https://discord.gg/tailwindcss", + onlineCount: 3_200, + memberCount: 12_450, +} + +export default async function Preview() { + return ( +
+
+ + + + +
+ +
+ ) +} diff --git a/apps/docs/components/docs/previews/download-sparkline.tsx b/apps/docs/components/docs/previews/download-sparkline.tsx new file mode 100644 index 0000000..bd4a182 --- /dev/null +++ b/apps/docs/components/docs/previews/download-sparkline.tsx @@ -0,0 +1,32 @@ +import { DownloadSparkline } from "@/registry/download-sparkline/download-sparkline" +import type { NpmDownloadPoint } from "@/registry/download-sparkline/lib/npm" + +function generateSampleData(days: number, base: number, trend: number): NpmDownloadPoint[] { + const points: NpmDownloadPoint[] = [] + const now = Date.now() + for (let i = 0; i < days; i++) { + const date = new Date(now - (days - 1 - i) * 86_400_000) + const noise = Math.sin(i * 0.8) * base * 0.15 + (Math.random() - 0.5) * base * 0.1 + points.push({ + day: date.toISOString().slice(0, 10), + downloads: Math.max(0, Math.round(base + i * trend + noise)), + }) + } + return points +} + +const trendingUp = generateSampleData(30, 45_000, 600) +const steady = generateSampleData(30, 120_000, 0) +const weekly = generateSampleData(7, 8_000, 200) + +export default async function Preview() { + return ( +
+
+ + + +
+
+ ) +} diff --git a/apps/docs/components/docs/previews/release-badge.tsx b/apps/docs/components/docs/previews/release-badge.tsx new file mode 100644 index 0000000..bee477c --- /dev/null +++ b/apps/docs/components/docs/previews/release-badge.tsx @@ -0,0 +1,41 @@ +import { ReleaseBadge } from "@/registry/release-badge/release-badge" +import type { GitHubReleaseData } from "@/registry/release-badge/lib/github" + +const sampleRelease: GitHubReleaseData = { + tag: "v15.3.1", + name: "Next.js 15.3.1", + preRelease: false, + draft: false, + publishedAt: new Date(Date.now() - 3 * 86_400_000).toISOString(), + url: "https://github.com/vercel/next.js/releases/latest", + body: null, + assetCount: 0, +} + +const preRelease: GitHubReleaseData = { + ...sampleRelease, + tag: "v16.0.0-canary.1", + name: "Next.js 16 Canary", + preRelease: true, +} + +export default async function Preview() { + return ( +
+
+ + + + +
+ + +
+ ) +} diff --git a/apps/docs/content/docs/components/ci-badge.mdx b/apps/docs/content/docs/components/ci-badge.mdx new file mode 100644 index 0000000..192e744 --- /dev/null +++ b/apps/docs/content/docs/components/ci-badge.mdx @@ -0,0 +1,159 @@ +--- +title: "CI Badge" +description: "GitHub Actions CI status badge showing passing, failing, pending, or cancelled state." +--- + +import { CIBadge } from "@/registry/ci-badge/ci-badge" + + + +## Installation + + + +## Usage + + + +`} /> + +Async server component — fetches the latest workflow run from the GitHub Actions API at build time, cached 10 minutes via ISR. + +## Layouts + +
+
+

Inline

+

+ Compact pill with status dot and label. The default layout. +

+ `, + preview: , + }, + { + label: "Failing", + code: ``, + preview: , + }, + { + label: "Pending", + code: ``, + preview: , + }, + ]} + /> +
+ +
+

With details

+

+ Show workflow name, branch, and duration alongside the status. +

+ `, + preview:
, + }, + ]} + /> +
+ +
+

Card

+

+ Feature card showing workflow name, status, branch, duration, and run time. +

+ `, + preview:
, + }, + ]} + /> +
+
+ +## API Reference + + + +## Notes + +- **ISR caching.** CI status data is cached for 10 minutes via `next.revalidate`. Set `GITHUB_TOKEN` to raise the rate limit from 60 → 5,000 req/hr. +- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately. +- **Status colors.** Passing = green, failing = red, pending = amber (animated pulse), cancelled/skipped = gray. diff --git a/apps/docs/content/docs/components/discord-badge.mdx b/apps/docs/content/docs/components/discord-badge.mdx new file mode 100644 index 0000000..391cb2c --- /dev/null +++ b/apps/docs/content/docs/components/discord-badge.mdx @@ -0,0 +1,129 @@ +--- +title: "Discord Badge" +description: "Live Discord server badge showing server name and online count with the Discord icon." +--- + +import { DiscordBadge } from "@/registry/discord-badge/discord-badge" + + + +## Installation + + + +## Usage + + + +`} /> + +Async server component — fetches server info from the Discord widget API at build time, cached 1 hour via ISR. + +## Layouts + +
+
+

Inline

+

+ Compact pill showing the Discord icon and server name. The default layout. +

+ `, + preview: , + }, + { + label: "Discord variant", + code: ``, + preview: , + }, + { + label: "With online", + code: ``, + preview: , + }, + ]} + /> +
+ +
+

Card

+

+ Feature card showing server name and online count. +

+ `, + preview:
, + }, + ]} + /> +
+
+ +## API Reference + + + +## Notes + +- **Widget required.** The target Discord server must have the widget enabled in Server Settings → Widget. If the widget is disabled, the badge returns `null`. +- **Online count only.** The public widget API only exposes online/presence count, not total members. Total member count requires a bot token — pass it manually via `data.memberCount` if you have it from another source. +- **ISR caching.** Server data is cached for 1 hour via `next.revalidate`. No API key or bot token required. +- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately. diff --git a/apps/docs/content/docs/components/download-sparkline.mdx b/apps/docs/content/docs/components/download-sparkline.mdx new file mode 100644 index 0000000..1737f03 --- /dev/null +++ b/apps/docs/content/docs/components/download-sparkline.mdx @@ -0,0 +1,237 @@ +--- +title: "Download Sparkline" +description: "Tiny inline SVG sparkline showing npm download trends over time." +--- + +import { DownloadSparkline } from "@/registry/download-sparkline/download-sparkline" + + + +## Installation + + + +## Usage + + + +`} /> + +Async server component — fetches daily download data from the npm API at build time, cached 1 hour via ISR. Pure SVG, zero charting dependencies. + +## Variants + +
+
+

Chart types

+

+ Three chart types: line (default), area, and bar. +

+ `, + preview: , + }, + { + label: "Area", + code: ``, + preview: , + }, + { + label: "Bar", + code: ``, + preview: , + }, + ]} + /> +
+ +
+

Time ranges

+

+ Three time ranges: last week, last month (default), and last year. +

+ `, + preview: , + }, + { + label: "Last month", + code: ``, + preview: , + }, + { + label: "Last year", + code: ``, + preview: , + }, + ]} + /> +
+
+ +## Examples + +
+
+

With trend indicator

+

+ Shows percentage change comparing the first half vs second half of the time range, with a colored arrow. +

+ `, + preview: , + }, + { + label: "Area with trend", + code: ``, + preview: , + }, + ]} + /> +
+ +
+

With baseline and date range

+

+ Baseline shows a faint dashed line at the average. Date range labels the time period below the chart. +

+ `, + preview: , + }, + { + label: "Everything", + code: ``, + preview: , + }, + ]} + /> +
+ +
+

Custom sizing

+ `, + preview: , + }, + { + label: "Compact", + code: ``, + preview: , + }, + ]} + /> +
+
+ +## API Reference + + + +## Notes + +- **ISR caching.** Download data is cached for 1 hour via `next.revalidate`. No API key required. +- **Pure SVG.** Zero charting library dependencies. The sparkline is rendered as a single inline SVG element. +- **Trend calculation.** The trend percentage compares the average daily downloads in the first half of the range to the second half. Green arrow = increasing, red arrow = decreasing. +- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately. +- **Scoped packages.** Supports scoped names like `@tanstack/react-query`. diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index b69c808..8caf317 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -13,16 +13,21 @@ "file-tree", "kbd", "stepper", - "---Open Source---", + "---GitHub---", "activity-graph", + "ci-badge", "commit-graph", "contributor-grid", "github-button-group", "github-stars-button", + "release-badge", + "repo-card", + "---Ecosystem---", + "discord-badge", + "download-sparkline", "license-badge", "npm-badge", "producthunt-button", - "repo-card", "---Dev Tools---", "cron-schedule", "env-table", diff --git a/apps/docs/content/docs/components/release-badge.mdx b/apps/docs/content/docs/components/release-badge.mdx new file mode 100644 index 0000000..6efc01b --- /dev/null +++ b/apps/docs/content/docs/components/release-badge.mdx @@ -0,0 +1,154 @@ +--- +title: "Release Badge" +description: "Live GitHub release badge showing latest tag, publish date, and pre-release indicator." +--- + +import { ReleaseBadge } from "@/registry/release-badge/release-badge" + + + +## Installation + + + +## Usage + + + +`} /> + +Async server component — fetches the latest GitHub release at build time, cached 1 hour via ISR. + +## Layouts + +
+
+

Inline

+

+ Compact pill showing the tag icon and version. The default layout. +

+ `, + preview: , + }, + { + label: "With date", + code: ``, + preview: , + }, + { + label: "Outline", + code: ``, + preview: , + }, + ]} + /> +
+ +
+

Row

+

+ Segmented horizontal strip showing tag, release name, and publish date. +

+ `, + preview:
, + }, + ]} + /> +
+ +
+

Card

+

+ Feature card showing release tag, name, publish date, and asset count. +

+ `, + preview:
, + }, + ]} + /> +
+
+ +## API Reference + + + +## Notes + +- **ISR caching.** Release data is cached for 1 hour via `next.revalidate`. Set `GITHUB_TOKEN` to raise the rate limit from 60 → 5,000 req/hr. +- **Pre-fetched data.** Pass the `data` prop to skip the API call — useful for static builds or when fetching data separately. +- **Pre-release indicator.** When the latest release is marked as a pre-release on GitHub, an amber "pre" badge appears automatically. diff --git a/apps/docs/lib/docs.ts b/apps/docs/lib/docs.ts index 1ac1722..a41548f 100644 --- a/apps/docs/lib/docs.ts +++ b/apps/docs/lib/docs.ts @@ -64,25 +64,34 @@ export const docsNav: NavGroup[] = [ ], }, { - title: "Open Source", + title: "GitHub", items: [ { title: "Activity Graph", href: "/docs/components/activity-graph", dateAdded: "2026-03-11" }, - { - title: "GitHub Stars Button", - href: "/docs/components/github-stars-button", - dateAdded: "2026-03-10", - }, + { title: "CI Badge", href: "/docs/components/ci-badge", dateAdded: "2026-04-22" }, + { title: "Commit Graph", href: "/docs/components/commit-graph", dateAdded: "2026-03-29" }, + { title: "Contributor Grid", href: "/docs/components/contributor-grid", dateAdded: "2026-03-29" }, { title: "GitHub Button Group", href: "/docs/components/github-button-group", dateAdded: "2026-03-11", }, + { + title: "GitHub Stars Button", + href: "/docs/components/github-stars-button", + dateAdded: "2026-03-10", + }, + { title: "Release Badge", href: "/docs/components/release-badge", dateAdded: "2026-04-22" }, + { title: "Repo Card", href: "/docs/components/repo-card", dateAdded: "2026-03-29" }, + ], + }, + { + title: "Ecosystem", + items: [ + { title: "Discord Badge", href: "/docs/components/discord-badge", dateAdded: "2026-04-22" }, + { title: "Download Sparkline", href: "/docs/components/download-sparkline", dateAdded: "2026-04-22" }, + { title: "License Badge", href: "/docs/components/license-badge", dateAdded: "2026-03-29" }, { title: "npm Badge", href: "/docs/components/npm-badge", dateAdded: "2026-03-12" }, { title: "Product Hunt", href: "/docs/components/producthunt-button", dateAdded: "2026-03-12" }, - { title: "Contributor Grid", href: "/docs/components/contributor-grid", dateAdded: "2026-03-29" }, - { title: "Commit Graph", href: "/docs/components/commit-graph", dateAdded: "2026-03-29" }, - { title: "License Badge", href: "/docs/components/license-badge", dateAdded: "2026-03-29" }, - { title: "Repo Card", href: "/docs/components/repo-card", dateAdded: "2026-03-29" }, ], }, { diff --git a/apps/docs/lib/releases.ts b/apps/docs/lib/releases.ts index 3ff6827..4b7f733 100644 --- a/apps/docs/lib/releases.ts +++ b/apps/docs/lib/releases.ts @@ -32,6 +32,50 @@ export interface Release { } export const releases: Release[] = [ + { + version: "2026.04.1", + date: "2026-04-22", + title: "Open Source Batch", + summary: + "Leaning harder into the open-source lane. Four new components that round out the GitHub and ecosystem coverage: a release badge that pulls your latest tag, a CI status badge for GitHub Actions, a Discord server widget, and a zero-dep SVG sparkline that shows npm download trends inline. Also split the sidebar into GitHub and Ecosystem sections because 13 items in one list was getting unwieldy.", + components: [ + { + name: "release-badge", + title: "Release Badge", + description: + "Live GitHub release badge showing latest tag, publish date, and pre-release indicator. Three layouts.", + category: "GitHub", + }, + { + name: "ci-badge", + title: "CI Badge", + description: + "GitHub Actions CI status badge with colored status dots for passing, failing, pending, and cancelled states.", + category: "GitHub", + }, + { + name: "discord-badge", + title: "Discord Badge", + description: + "Live Discord server badge showing server name and online count via the public widget API. Includes a blurple variant.", + category: "Ecosystem", + }, + { + name: "download-sparkline", + title: "Download Sparkline", + description: + "Tiny inline SVG sparkline showing npm download trends. Three chart types, trend indicator, average baseline, and date range labels. Zero charting dependencies.", + category: "Ecosystem", + }, + ], + improvements: [ + { + title: "Sidebar reorganization", + description: + "Split the Open Source section into GitHub (repo-specific components) and Ecosystem (npm, Discord, licensing, platforms) for easier navigation.", + }, + ], + }, { version: "2026.04.0", date: "2026-04-07", diff --git a/apps/docs/public/previews/ci-badge-dark.png b/apps/docs/public/previews/ci-badge-dark.png new file mode 100644 index 0000000..e074329 Binary files /dev/null and b/apps/docs/public/previews/ci-badge-dark.png differ diff --git a/apps/docs/public/previews/ci-badge-light.png b/apps/docs/public/previews/ci-badge-light.png new file mode 100644 index 0000000..56be2d7 Binary files /dev/null and b/apps/docs/public/previews/ci-badge-light.png differ diff --git a/apps/docs/public/previews/discord-badge-dark.png b/apps/docs/public/previews/discord-badge-dark.png new file mode 100644 index 0000000..508730d Binary files /dev/null and b/apps/docs/public/previews/discord-badge-dark.png differ diff --git a/apps/docs/public/previews/discord-badge-light.png b/apps/docs/public/previews/discord-badge-light.png new file mode 100644 index 0000000..c2a29c5 Binary files /dev/null and b/apps/docs/public/previews/discord-badge-light.png differ diff --git a/apps/docs/public/previews/download-sparkline-dark.png b/apps/docs/public/previews/download-sparkline-dark.png new file mode 100644 index 0000000..657ac3a Binary files /dev/null and b/apps/docs/public/previews/download-sparkline-dark.png differ diff --git a/apps/docs/public/previews/download-sparkline-light.png b/apps/docs/public/previews/download-sparkline-light.png new file mode 100644 index 0000000..4a609f3 Binary files /dev/null and b/apps/docs/public/previews/download-sparkline-light.png differ diff --git a/apps/docs/public/previews/release-badge-dark.png b/apps/docs/public/previews/release-badge-dark.png new file mode 100644 index 0000000..2ddd6ae Binary files /dev/null and b/apps/docs/public/previews/release-badge-dark.png differ diff --git a/apps/docs/public/previews/release-badge-light.png b/apps/docs/public/previews/release-badge-light.png new file mode 100644 index 0000000..42e743c Binary files /dev/null and b/apps/docs/public/previews/release-badge-light.png differ diff --git a/apps/docs/registry.json b/apps/docs/registry.json index dad6d97..dcaaf9c 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -682,6 +682,92 @@ "type": "registry:component" } ] + }, + { + "name": "release-badge", + "type": "registry:component", + "title": "Release Badge", + "description": "Live GitHub release badge showing latest tag, publish date, and pre-release indicator. Three layouts: inline pill, horizontal row, and expanded card. Async server component — fetches data at build time with ISR.", + "dependencies": [ + "class-variance-authority" + ], + "categories": [ + "github", + "open-source" + ], + "files": [ + { + "path": "registry/release-badge/release-badge.tsx", + "type": "registry:component" + }, + { + "path": "registry/release-badge/lib/github.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "discord-badge", + "type": "registry:component", + "title": "Discord Badge", + "description": "Live Discord server badge showing server name and online count with the Discord icon. Two layouts: inline pill and expanded card. Async server component — fetches data at build time with ISR via the public widget API.", + "dependencies": [ + "class-variance-authority" + ], + "categories": [ + "community", + "open-source" + ], + "files": [ + { + "path": "registry/discord-badge/discord-badge.tsx", + "type": "registry:component" + }, + { + "path": "registry/discord-badge/lib/discord.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "download-sparkline", + "type": "registry:component", + "title": "Download Sparkline", + "description": "Tiny inline SVG sparkline showing npm download trends over time. Three chart types, trend indicator, date range, and average baseline. Pure SVG, zero charting dependencies. Async server component — fetches data at build time with ISR.", + "categories": [ + "open-source", + "code" + ], + "files": [ + { + "path": "registry/download-sparkline/download-sparkline.tsx", + "type": "registry:component" + }, + { + "path": "registry/download-sparkline/lib/npm.ts", + "type": "registry:lib" + } + ] + }, + { + "name": "ci-badge", + "type": "registry:component", + "title": "CI Badge", + "description": "GitHub Actions CI status badge showing passing, failing, pending, or cancelled state with colored status dot and optional duration. Two layouts: inline pill and expanded card. Async server component — fetches data at build time with ISR.", + "categories": [ + "github", + "open-source" + ], + "files": [ + { + "path": "registry/ci-badge/ci-badge.tsx", + "type": "registry:component" + }, + { + "path": "registry/ci-badge/lib/github.ts", + "type": "registry:lib" + } + ] } ] } diff --git a/apps/docs/registry/ci-badge/ci-badge.tsx b/apps/docs/registry/ci-badge/ci-badge.tsx new file mode 100644 index 0000000..da77bee --- /dev/null +++ b/apps/docs/registry/ci-badge/ci-badge.tsx @@ -0,0 +1,397 @@ +/** + * jalco-ui + * CIBadge + * by Justin Levine + * ui.justinlevine.me + * + * GitHub Actions CI status badge showing passing, failing, pending, or cancelled + * state with colored status dot and optional duration. Two layouts: inline pill + * and expanded card. Async server component — fetches data at build time with ISR. + * + * Props: + * - owner: GitHub username or organization + * - repo: GitHub repository name + * - workflow?: workflow filename (e.g. "ci.yml") or workflow ID + * - branch?: branch to filter by + * - layout?: "inline" | "card" (default "inline") + * - size?: badge size (inline only) + * - showDuration?: show run duration + * - showBranch?: show branch name + * - showWorkflow?: show workflow name + * - data?: pre-fetched CIStatusData to skip the API call + * + * Notes: + * - Async server component — no client JS required + * - Fetches api.github.com at build time, cached 10 minutes via ISR + * - Optional GITHUB_TOKEN env var raises rate limit to 5,000 req/hr + */ + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { + fetchCIStatus, + formatDuration, + formatRelativeDate, + type CIStatus, + type CIStatusData, +} from "@/registry/ci-badge/lib/github" + +const statusConfig: Record< + CIStatus, + { label: string; dotClass: string; textClass: string } +> = { + success: { + label: "passing", + dotClass: "bg-emerald-500", + textClass: "text-emerald-700 dark:text-emerald-400", + }, + failure: { + label: "failing", + dotClass: "bg-red-500", + textClass: "text-red-700 dark:text-red-400", + }, + pending: { + label: "pending", + dotClass: "bg-amber-500 animate-pulse", + textClass: "text-amber-700 dark:text-amber-400", + }, + cancelled: { + label: "cancelled", + dotClass: "bg-zinc-400", + textClass: "text-zinc-600 dark:text-zinc-400", + }, + skipped: { + label: "skipped", + dotClass: "bg-zinc-400", + textClass: "text-zinc-600 dark:text-zinc-400", + }, +} + +function ClockIcon({ className }: { className?: string }) { + return ( + + ) +} + +function GitBranchIcon({ className }: { className?: string }) { + return ( + + ) +} + +function WorkflowIcon({ className }: { className?: string }) { + return ( + + ) +} + +const inlineVariants = cva( + "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", + { + variants: { + size: { + sm: "h-7 gap-1.5 px-2.5 text-xs", + default: "h-8 gap-2 px-3 text-sm", + lg: "h-9 gap-2.5 px-4 text-sm", + }, + }, + defaultVariants: { + size: "default", + }, + } +) + +type BadgeSize = NonNullable["size"]> + +interface CIBadgeBaseProps { + /** GitHub username or organization (e.g. "vercel"). */ + owner: string + /** GitHub repository name (e.g. "next.js"). */ + repo: string + /** Workflow filename (e.g. "ci.yml") or workflow ID. Fetches the latest run across all workflows if omitted. */ + workflow?: string + /** Branch to filter by. Uses the repo's default branch if omitted. */ + branch?: string + /** Show run duration. @default true for card, false for inline */ + showDuration?: boolean + /** Show branch name. @default true for card, false for inline */ + showBranch?: boolean + /** Show workflow name. @default false for inline, true for card */ + showWorkflow?: boolean + /** Pre-fetched CI status data. When provided, skips the GitHub API call. */ + data?: CIStatusData +} + +interface CIBadgeInlineProps + extends CIBadgeBaseProps, + Omit, "children"> { + /** @default "inline" */ + layout?: "inline" + size?: BadgeSize +} + +interface CIBadgeCardProps + extends CIBadgeBaseProps, + Omit, "children"> { + layout: "card" + size?: never +} + +type CIBadgeProps = CIBadgeInlineProps | CIBadgeCardProps + +function InlineLayout({ + ci, + showDuration, + showBranch, + showWorkflow, + size, + className, +}: { + ci: CIStatusData + showDuration: boolean + showBranch: boolean + showWorkflow: boolean + size: BadgeSize + className?: string +}) { + const config = statusConfig[ci.status] + + return ( +
+ + ) +} + +function CardLayout({ + ci, + showDuration, + showBranch, + showWorkflow, + className, +}: { + ci: CIStatusData + showDuration: boolean + showBranch: boolean + showWorkflow: boolean + className?: string +}) { + const config = statusConfig[ci.status] + + const meta: { icon: React.ReactNode; value: string }[] = [] + + if (showBranch) { + meta.push({ + icon: , + value: ci.branch, + }) + } + + if (showDuration && ci.durationSeconds != null) { + meta.push({ + icon: , + value: formatDuration(ci.durationSeconds), + }) + } + + if (ci.startedAt) { + meta.push({ + icon: null, + value: formatRelativeDate(ci.startedAt), + }) + } + + return ( + +
+
+ {showWorkflow && ( + <> + + + {ci.workflowName} + + + )} +
+ + +
+ + {meta.length > 0 && ( +
+ {meta.map((item) => ( + + {item.icon} + {item.value} + + ))} +
+ )} +
+ ) +} + +async function CIBadge(props: CIBadgeProps) { + const { + owner, + repo, + workflow, + branch, + layout = "inline", + data: dataProp, + className, + } = props + + const ci = dataProp ?? (await fetchCIStatus(owner, repo, workflow, branch)) + if (!ci) return null + + if (layout === "card") { + const { + showDuration = true, + showBranch = true, + showWorkflow = true, + } = props + return ( + + ) + } + + const { + showDuration = false, + showBranch = false, + showWorkflow = false, + size = "default", + } = props as CIBadgeInlineProps + return ( + + ) +} + +export { CIBadge, inlineVariants as ciBadgeInlineVariants } +export type { CIBadgeProps, CIBadgeInlineProps, CIBadgeCardProps, CIStatusData } diff --git a/apps/docs/registry/ci-badge/lib/github.ts b/apps/docs/registry/ci-badge/lib/github.ts new file mode 100644 index 0000000..629711d --- /dev/null +++ b/apps/docs/registry/ci-badge/lib/github.ts @@ -0,0 +1,159 @@ +/** + * jalco-ui + * lib/github + * by Justin Levine + * ui.justinlevine.me + * + * GitHub Actions API client for fetching workflow run status. + */ + +export type CIStatus = + | "success" + | "failure" + | "pending" + | "cancelled" + | "skipped" + +export interface CIStatusData { + /** Workflow name (e.g. "CI", "Deploy"). */ + workflowName: string + /** Current status of the latest run. */ + status: CIStatus + /** HTML URL to the workflow run on GitHub. */ + url: string + /** Branch the run was triggered on. */ + branch: string + /** ISO date string of when the run started. */ + startedAt: string | null + /** Duration in seconds (if completed). */ + durationSeconds: number | null +} + +/** + * Fetch the latest workflow run status for a GitHub repository. + * + * - Uses the public GitHub REST API. + * - Optionally authenticates with `process.env.GITHUB_TOKEN` to raise + * the rate limit from 60 → 5,000 requests per hour. + * - Caches the result for 10 minutes via Next.js ISR. + * + * @param owner - GitHub username or organization + * @param repo - GitHub repository name + * @param workflow - Workflow filename (e.g. "ci.yml") or workflow ID + * @param branch - Branch to filter runs by (defaults to repo default branch) + * + * Returns `null` if the request fails or no runs are found. + */ +export async function fetchCIStatus( + owner: string, + repo: string, + workflow?: string, + branch?: string +): Promise { + try { + const params = new URLSearchParams({ per_page: "1" }) + if (branch) params.set("branch", branch) + + const workflowPath = workflow + ? `/workflows/${encodeURIComponent(workflow)}` + : "" + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/actions${workflowPath}/runs?${params}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + }, + next: { revalidate: 600 }, + } + ) + if (!response.ok) return null + const data = await response.json() + + const run = data.workflow_runs?.[0] + if (!run) return null + + const status = mapStatus(run.status, run.conclusion) + + let durationSeconds: number | null = null + if (run.run_started_at && run.updated_at) { + const start = new Date(run.run_started_at).getTime() + const end = new Date(run.updated_at).getTime() + if (end > start) { + durationSeconds = Math.round((end - start) / 1000) + } + } + + return { + workflowName: run.name ?? "CI", + status, + url: run.html_url, + branch: run.head_branch ?? branch ?? "main", + startedAt: run.run_started_at ?? null, + durationSeconds, + } + } catch { + return null + } +} + +function mapStatus( + status: string, + conclusion: string | null +): CIStatus { + if (status === "completed") { + if (conclusion === "success") return "success" + if (conclusion === "failure") return "failure" + if (conclusion === "cancelled") return "cancelled" + if (conclusion === "skipped") return "skipped" + return "failure" + } + return "pending" +} + +/** + * Format a duration in seconds into a human-readable label. + * + * - `45` → "45s" + * - `125` → "2m 5s" + * - `3661` → "1h 1m" + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s` + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + if (mins < 60) { + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m` + } + const hrs = Math.floor(mins / 60) + const remainMins = mins % 60 + return remainMins > 0 ? `${hrs}h ${remainMins}m` : `${hrs}h` +} + +/** + * Format an ISO date into a relative label. + */ +export function formatRelativeDate(iso: string): string { + const date = new Date(iso) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffDays = Math.floor(diffMs / 86_400_000) + + if (diffDays === 0) { + const diffHrs = Math.floor(diffMs / 3_600_000) + if (diffHrs === 0) { + const diffMins = Math.floor(diffMs / 60_000) + return diffMins <= 1 ? "just now" : `${diffMins}m ago` + } + return `${diffHrs}h ago` + } + if (diffDays === 1) return "yesterday" + if (diffDays < 30) return `${diffDays}d ago` + const months = Math.floor(diffDays / 30) + if (months < 12) return `${months}mo ago` + const years = Math.floor(diffDays / 365) + return `${years}y ago` +} diff --git a/apps/docs/registry/discord-badge/discord-badge.tsx b/apps/docs/registry/discord-badge/discord-badge.tsx new file mode 100644 index 0000000..11ef7da --- /dev/null +++ b/apps/docs/registry/discord-badge/discord-badge.tsx @@ -0,0 +1,338 @@ +/** + * jalco-ui + * DiscordBadge + * by Justin Levine + * ui.justinlevine.me + * + * Live Discord server badge showing server name and online count with the Discord icon. + * Two layouts: inline pill and expanded card. Async server component — + * fetches data at build time with ISR via the public widget API. + * + * Props: + * - serverId: Discord server/guild ID + * - layout?: "inline" | "card" (default "inline") + * - variant?: visual style variant (inline only) + * - size?: badge size (inline only) + * - iconStyle?: "currentColor" | "discord" (default "currentColor") + * - inviteUrl?: override invite link + * - showOnline?: show online member count + * - data?: pre-fetched DiscordServerData to skip the API call + * + * Notes: + * - Async server component — no client JS required + * - Requires the target server to have the widget enabled + * - Fetches discord.com widget API at build time, cached 1 hour via ISR + * - No API key or bot token required + */ + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { + fetchDiscordServer, + formatMemberCount, + type DiscordServerData, +} from "@/registry/discord-badge/lib/discord" + +function DiscordIcon({ + iconStyle = "currentColor", + className, +}: { + iconStyle?: "currentColor" | "discord" + className?: string +}) { + return ( + + ) +} + +function UsersIcon({ className }: { className?: string }) { + return ( + + ) +} + +const inlineVariants = cva( + "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", + { + variants: { + variant: { + default: + "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground", + primary: + "rounded-md bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + secondary: + "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + outline: + "rounded-md border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + ghost: + "rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + subtle: + "rounded-full border border-border/60 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground", + discord: + "rounded-md bg-[#5865F2] text-white shadow-xs hover:bg-[#4752C4]", + }, + size: { + sm: "h-7 gap-1.5 px-2.5 text-xs [&_svg]:size-3.5", + default: "h-8 gap-2 px-3 text-sm [&_svg]:size-4", + lg: "h-9 gap-2.5 px-4 text-sm [&_svg]:size-4", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +type InlineVariant = NonNullable["variant"]> +type BadgeSize = NonNullable["size"]> + +interface DiscordBadgeBaseProps { + /** Discord server/guild ID. */ + serverId: string + /** + * Icon color style: + * - `"currentColor"` — inherits text color from the variant (default) + * - `"discord"` — Discord blurple (#5865F2) + */ + iconStyle?: "currentColor" | "discord" + /** Override the invite link. By default uses the widget's instant invite URL. */ + inviteUrl?: string + /** Show online member count. @default true for card, false for inline */ + showOnline?: boolean + /** Pre-fetched server data. When provided, skips the Discord API call. */ + data?: DiscordServerData +} + +interface DiscordBadgeInlineProps + extends DiscordBadgeBaseProps, + Omit, "children"> { + /** @default "inline" */ + layout?: "inline" + variant?: InlineVariant + size?: BadgeSize +} + +interface DiscordBadgeCardProps + extends DiscordBadgeBaseProps, + Omit, "children"> { + layout: "card" + variant?: never + size?: never +} + +type DiscordBadgeProps = DiscordBadgeInlineProps | DiscordBadgeCardProps + +function InlineLayout({ + server, + iconStyle, + showOnline, + inviteUrl, + variant, + size, + className, +}: { + server: DiscordServerData + iconStyle: "currentColor" | "discord" + showOnline: boolean + inviteUrl: string | null + variant: InlineVariant + size: BadgeSize + className?: string +}) { + const href = inviteUrl ?? server.instantInvite + const Comp = href ? "a" : "span" + const linkProps = href + ? { href, target: "_blank" as const, rel: "noopener noreferrer" } + : {} + + return ( + + + {server.name} + {showOnline && ( + <> + + ) +} + +function CardLayout({ + server, + iconStyle, + showOnline, + inviteUrl, + className, +}: { + server: DiscordServerData + iconStyle: "currentColor" | "discord" + showOnline: boolean + inviteUrl: string | null + className?: string +}) { + const href = inviteUrl ?? server.instantInvite + + const content = ( + <> +
+
+ + + {server.name} + +
+ {showOnline && ( + + + {formatMemberCount(server.onlineCount)} online + + )} +
+ +
+ + + {formatMemberCount(server.onlineCount)} online + + {server.memberCount != null && ( + + + {formatMemberCount(server.memberCount)} members + + )} +
+ + ) + + if (href) { + return ( + + {content} + + ) + } + + return ( +
+ {content} +
+ ) +} + +async function DiscordBadge(props: DiscordBadgeProps) { + const { + serverId, + layout = "inline", + iconStyle = "currentColor", + inviteUrl, + data: dataProp, + className, + } = props + + const server = dataProp ?? (await fetchDiscordServer(serverId)) + if (!server) return null + + const resolvedInvite = inviteUrl ?? null + + if (layout === "card") { + const { showOnline = true } = props + return ( + + ) + } + + const { + showOnline = false, + variant = "default", + size = "default", + } = props as DiscordBadgeInlineProps + return ( + + ) +} + +export { DiscordBadge, inlineVariants as discordBadgeInlineVariants } +export type { + DiscordBadgeProps, + DiscordBadgeInlineProps, + DiscordBadgeCardProps, + DiscordServerData, +} diff --git a/apps/docs/registry/discord-badge/lib/discord.ts b/apps/docs/registry/discord-badge/lib/discord.ts new file mode 100644 index 0000000..255c16c --- /dev/null +++ b/apps/docs/registry/discord-badge/lib/discord.ts @@ -0,0 +1,77 @@ +/** + * jalco-ui + * lib/discord + * by Justin Levine + * ui.justinlevine.me + * + * Discord widget API client for fetching server metadata (online count, name). + * + * The public widget API only exposes online/presence count, not total member + * count. Total members requires a bot token. Pass `memberCount` manually via + * the `data` prop if you have it from another source. + */ + +export interface DiscordServerData { + /** Server/guild ID. */ + id: string + /** Server name. */ + name: string + /** Instant invite URL (if widget is enabled). */ + instantInvite: string | null + /** Number of members currently online (from widget API). */ + onlineCount: number + /** Total member count. Only available if provided manually — the widget API does not expose this. */ + memberCount?: number +} + +/** + * Fetch public metadata for a Discord server using the widget API. + * + * - Requires the server to have the widget enabled in Server Settings → Widget. + * - No API key or bot token required. + * - Caches the result for 1 hour via Next.js ISR (`next.revalidate`). + * + * Returns `null` if the request fails or the widget is disabled. + */ +export async function fetchDiscordServer( + serverId: string +): Promise { + try { + const response = await fetch( + `https://discord.com/api/guilds/${serverId}/widget.json`, + { next: { revalidate: 3600 } } + ) + if (!response.ok) return null + const data = await response.json() + + if (typeof data.name !== "string") return null + + return { + id: data.id, + name: data.name, + instantInvite: data.instant_invite ?? null, + onlineCount: data.presence_count ?? 0, + } + } catch { + return null + } +} + +/** + * Format a member count for compact display. + * + * - `236000` → `"236K"` + * - `1500` → `"1.5K"` + * - `842` → `"842"` + */ +export function formatMemberCount(count: number): string { + if (count >= 1_000_000) { + const value = count / 1_000_000 + return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}M` + } + if (count >= 1_000) { + const value = count / 1_000 + return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}K` + } + return count.toLocaleString("en-US") +} diff --git a/apps/docs/registry/download-sparkline/download-sparkline.tsx b/apps/docs/registry/download-sparkline/download-sparkline.tsx new file mode 100644 index 0000000..647c1d8 --- /dev/null +++ b/apps/docs/registry/download-sparkline/download-sparkline.tsx @@ -0,0 +1,530 @@ +/** + * jalco-ui + * DownloadSparkline + * by Justin Levine + * ui.justinlevine.me + * + * Tiny inline SVG sparkline showing npm download trends over time. Pure SVG, + * zero charting dependencies. Async server component — fetches data at build + * time with ISR. + * + * Props: + * - package: npm package name + * - range?: "last-week" | "last-month" | "last-year" (default "last-month") + * - variant?: "line" | "area" | "bar" (default "line") + * - color?: stroke/fill color (default "currentColor") + * - width?: SVG width in px (default 120) + * - height?: SVG height in px (default 32) + * - strokeWidth?: line stroke width (default 1.5) + * - showEndpoint?: show a dot at the latest value (default true) + * - showLabel?: show formatted total downloads label (default false) + * - showTrend?: show percentage change indicator (default false) + * - showDateRange?: show date range below the chart (default false) + * - showBaseline?: show a faint average reference line (default false) + * - data?: pre-fetched NpmDownloadPoint[] to skip the API call + * + * Notes: + * - Async server component — no client JS required + * - Fetches api.npmjs.org at build time, cached 1 hour via ISR + * - No API key required + */ + +import * as React from "react" +import { cn } from "@/lib/utils" +import { + fetchNpmDownloads, + formatDownloads, + type NpmDownloadPoint, + type NpmDownloadRange, +} from "@/registry/download-sparkline/lib/npm" + +interface DownloadSparklineProps extends React.ComponentProps<"div"> { + /** npm package name (e.g. "react", "@tanstack/react-query"). */ + package: string + /** Time range. @default "last-month" */ + range?: NpmDownloadRange + /** Sparkline chart type. @default "line" */ + variant?: "line" | "area" | "bar" + /** Stroke/fill color. @default "currentColor" */ + color?: string + /** SVG width in pixels. @default 120 */ + width?: number + /** SVG height in pixels. @default 32 */ + height?: number + /** Line stroke width. @default 1.5 */ + strokeWidth?: number + /** Show a dot at the latest data point. @default true */ + showEndpoint?: boolean + /** Show formatted total downloads label. @default false */ + showLabel?: boolean + /** Show percentage change trend indicator (compares first half vs second half). @default false */ + showTrend?: boolean + /** Show date range below the chart (e.g. "Mar 22 – Apr 21"). @default false */ + showDateRange?: boolean + /** Show a faint dashed line at the average value. @default false */ + showBaseline?: boolean + /** Pre-fetched download data. When provided, skips the npm API call. */ + data?: NpmDownloadPoint[] +} + +function computeStats(points: NpmDownloadPoint[]) { + const total = points.reduce((sum, p) => sum + p.downloads, 0) + const avg = total / points.length + + const mid = Math.floor(points.length / 2) + const firstHalf = points.slice(0, mid) + const secondHalf = points.slice(mid) + const firstAvg = + firstHalf.reduce((s, p) => s + p.downloads, 0) / (firstHalf.length || 1) + const secondAvg = + secondHalf.reduce((s, p) => s + p.downloads, 0) / (secondHalf.length || 1) + + const trendPct = + firstAvg > 0 ? ((secondAvg - firstAvg) / firstAvg) * 100 : 0 + + const max = Math.max(...points.map((p) => p.downloads), 1) + const min = Math.min(...points.map((p) => p.downloads)) + + return { total, avg, max, min, trendPct } +} + +function formatShortDate(iso: string): string { + const d = new Date(iso + "T00:00:00") + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +function buildLinePath( + points: NpmDownloadPoint[], + width: number, + height: number, + padding: number +): string { + if (points.length === 0) return "" + + const max = Math.max(...points.map((p) => p.downloads), 1) + const min = Math.min(...points.map((p) => p.downloads)) + const range = max - min || 1 + const drawHeight = height - padding * 2 + const drawWidth = width - padding * 2 + + return points + .map((p, i) => { + const x = padding + (i / (points.length - 1)) * drawWidth + const y = + padding + drawHeight - ((p.downloads - min) / range) * drawHeight + return `${i === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}` + }) + .join(" ") +} + +function buildAreaPath( + points: NpmDownloadPoint[], + width: number, + height: number, + padding: number +): string { + if (points.length === 0) return "" + + const linePath = buildLinePath(points, width, height, padding) + const drawWidth = width - padding * 2 + const lastX = padding + drawWidth + const firstX = padding + const bottom = height - padding + + return `${linePath} L ${lastX.toFixed(2)} ${bottom.toFixed(2)} L ${firstX.toFixed(2)} ${bottom.toFixed(2)} Z` +} + +function getEndpoint( + points: NpmDownloadPoint[], + width: number, + height: number, + padding: number +): { x: number; y: number } | null { + if (points.length === 0) return null + + const max = Math.max(...points.map((p) => p.downloads), 1) + const min = Math.min(...points.map((p) => p.downloads)) + const range = max - min || 1 + const drawHeight = height - padding * 2 + const drawWidth = width - padding * 2 + + const last = points[points.length - 1] + return { + x: padding + drawWidth, + y: + padding + + drawHeight - + ((last.downloads - min) / range) * drawHeight, + } +} + +function getBaselineY( + avg: number, + points: NpmDownloadPoint[], + height: number, + padding: number +): number { + const max = Math.max(...points.map((p) => p.downloads), 1) + const min = Math.min(...points.map((p) => p.downloads)) + const range = max - min || 1 + const drawHeight = height - padding * 2 + return padding + drawHeight - ((avg - min) / range) * drawHeight +} + +function BaselineLine({ + y, + width, + padding, + color, +}: { + y: number + width: number + padding: number + color: string +}) { + return ( + + ) +} + +function LineSparkline({ + points, + width, + height, + color, + strokeWidth, + showEndpoint, + showBaseline, + avg, +}: { + points: NpmDownloadPoint[] + width: number + height: number + color: string + strokeWidth: number + showEndpoint: boolean + showBaseline: boolean + avg: number +}) { + const padding = 2 + strokeWidth + const path = buildLinePath(points, width, height, padding) + const endpoint = showEndpoint + ? getEndpoint(points, width, height, padding) + : null + const baselineY = showBaseline + ? getBaselineY(avg, points, height, padding) + : null + + return ( + + ) +} + +function AreaSparkline({ + points, + width, + height, + color, + strokeWidth, + showEndpoint, + showBaseline, + avg, +}: { + points: NpmDownloadPoint[] + width: number + height: number + color: string + strokeWidth: number + showEndpoint: boolean + showBaseline: boolean + avg: number +}) { + const padding = 2 + strokeWidth + const linePath = buildLinePath(points, width, height, padding) + const areaPath = buildAreaPath(points, width, height, padding) + const endpoint = showEndpoint + ? getEndpoint(points, width, height, padding) + : null + const baselineY = showBaseline + ? getBaselineY(avg, points, height, padding) + : null + + return ( + + ) +} + +function BarSparkline({ + points, + width, + height, + color, + showBaseline, + avg, +}: { + points: NpmDownloadPoint[] + width: number + height: number + color: string + showBaseline: boolean + avg: number +}) { + if (points.length === 0) return null + + const padding = 2 + const max = Math.max(...points.map((p) => p.downloads), 1) + const min = Math.min(...points.map((p) => p.downloads)) + const range = max - min || 1 + const drawHeight = height - padding * 2 + const drawWidth = width - padding * 2 + const gap = 1 + const barWidth = Math.max( + 1, + (drawWidth - gap * (points.length - 1)) / points.length + ) + const baselineY = showBaseline + ? getBaselineY(avg, points, height, padding) + : null + + return ( + + ) +} + +function TrendIndicator({ trendPct }: { trendPct: number }) { + const isUp = trendPct > 0 + const isFlat = Math.abs(trendPct) < 0.5 + const formatted = `${isUp ? "+" : ""}${trendPct.toFixed(1)}%` + + if (isFlat) { + return ( + + {formatted} + + ) + } + + return ( + + + {formatted} + + ) +} + +async function DownloadSparkline({ + package: packageName, + range = "last-month", + variant = "line", + color = "currentColor", + width = 120, + height = 32, + strokeWidth = 1.5, + showEndpoint = true, + showLabel = false, + showTrend = false, + showDateRange = false, + showBaseline = false, + data: dataProp, + className, + ...rest +}: DownloadSparklineProps) { + const points = dataProp ?? (await fetchNpmDownloads(packageName, range)) + if (points.length === 0) return null + + const stats = computeStats(points) + + const rangeLabel = + range === "last-week" + ? "7d" + : range === "last-year" + ? "1y" + : "30d" + + const dateStart = points[0].day + const dateEnd = points[points.length - 1].day + + const chartProps = { showBaseline, avg: stats.avg } + + return ( +
+
+ {variant === "bar" ? ( + + ) : variant === "area" ? ( + + ) : ( + + )} +
+ {showLabel && ( + + {formatDownloads(stats.total)} + /{rangeLabel} + + )} + {showTrend && } +
+
+ {showDateRange && ( + + {formatShortDate(dateStart)} – {formatShortDate(dateEnd)} + + )} +
+ ) +} + +export { DownloadSparkline } +export type { DownloadSparklineProps, NpmDownloadPoint, NpmDownloadRange } diff --git a/apps/docs/registry/download-sparkline/lib/npm.ts b/apps/docs/registry/download-sparkline/lib/npm.ts new file mode 100644 index 0000000..5ecfe61 --- /dev/null +++ b/apps/docs/registry/download-sparkline/lib/npm.ts @@ -0,0 +1,69 @@ +/** + * jalco-ui + * lib/npm + * by Justin Levine + * ui.justinlevine.me + * + * npm API client for fetching download time-series data for sparkline rendering. + */ + +export interface NpmDownloadPoint { + /** ISO date string (YYYY-MM-DD). */ + day: string + /** Download count for this day. */ + downloads: number +} + +export type NpmDownloadRange = "last-week" | "last-month" | "last-year" + +/** + * Fetch daily download counts for an npm package over a given range. + * + * Uses the npm downloads API: + * - `api.npmjs.org/downloads/range//` + * - No API key required. + * - Caches the result for 1 hour via Next.js ISR. + * + * Returns an empty array if the request fails or the package doesn't exist. + */ +export async function fetchNpmDownloads( + packageName: string, + range: NpmDownloadRange = "last-month" +): Promise { + const encoded = encodeURIComponent(packageName) + + try { + const response = await fetch( + `https://api.npmjs.org/downloads/range/${range}/${encoded}`, + { next: { revalidate: 3600 } } + ) + if (!response.ok) return [] + + const data = await response.json() + if (!Array.isArray(data.downloads)) return [] + + return data.downloads.map( + (d: { day: string; downloads: number }) => ({ + day: d.day, + downloads: d.downloads, + }) + ) + } catch { + return [] + } +} + +/** + * Format a download count for compact display. + */ +export function formatDownloads(count: number): string { + if (count >= 1_000_000) { + const value = count / 1_000_000 + return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}M` + } + if (count >= 1_000) { + const value = count / 1_000 + return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}K` + } + return count.toLocaleString("en-US") +} diff --git a/apps/docs/registry/release-badge/lib/github.ts b/apps/docs/registry/release-badge/lib/github.ts new file mode 100644 index 0000000..6f38bef --- /dev/null +++ b/apps/docs/registry/release-badge/lib/github.ts @@ -0,0 +1,98 @@ +/** + * jalco-ui + * lib/github + * by Justin Levine + * ui.justinlevine.me + * + * GitHub API client for fetching latest release metadata (tag, date, pre-release, notes). + */ + +export interface GitHubReleaseData { + /** Tag name (e.g. "v1.2.0", "2026.04.0"). */ + tag: string + /** Release title (e.g. "Batch 1"). */ + name: string | null + /** Whether this is marked as a pre-release. */ + preRelease: boolean + /** Whether this is a draft release. */ + draft: boolean + /** ISO date string of the release publish date. */ + publishedAt: string + /** HTML URL to the release page on GitHub. */ + url: string + /** Release body / notes (markdown). */ + body: string | null + /** Number of release assets (downloadable files). */ + assetCount: number +} + +/** + * Fetch the latest release for a GitHub repository. + * + * - Uses the public GitHub REST API — no API key required. + * - Optionally authenticates with `process.env.GITHUB_TOKEN` to raise + * the rate limit from 60 → 5,000 requests per hour. + * - Caches the result for 1 hour via Next.js ISR (`next.revalidate`). + * + * Returns `null` if the request fails or the repo has no releases. + */ +export async function fetchLatestRelease( + owner: string, + repo: string +): Promise { + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases/latest`, + { + headers: { + Accept: "application/vnd.github.v3+json", + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + }, + next: { revalidate: 3600 }, + } + ) + if (!response.ok) return null + const data = await response.json() + + if (typeof data.tag_name !== "string") return null + + return { + tag: data.tag_name, + name: data.name ?? null, + preRelease: data.prerelease === true, + draft: data.draft === true, + publishedAt: data.published_at, + url: data.html_url, + body: data.body ?? null, + assetCount: Array.isArray(data.assets) ? data.assets.length : 0, + } + } catch { + return null + } +} + +/** + * Format an ISO date into a relative or short label. + * + * - Same day → "today" + * - Yesterday → "yesterday" + * - Within 30 days → "Xd ago" + * - Within 12 months → "Xmo ago" + * - Older → "Xy ago" + */ +export function formatRelativeDate(iso: string): string { + const date = new Date(iso) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffDays = Math.floor(diffMs / 86_400_000) + + if (diffDays === 0) return "today" + if (diffDays === 1) return "yesterday" + if (diffDays < 30) return `${diffDays}d ago` + const months = Math.floor(diffDays / 30) + if (months < 12) return `${months}mo ago` + const years = Math.floor(diffDays / 365) + return `${years}y ago` +} diff --git a/apps/docs/registry/release-badge/release-badge.tsx b/apps/docs/registry/release-badge/release-badge.tsx new file mode 100644 index 0000000..39f673c --- /dev/null +++ b/apps/docs/registry/release-badge/release-badge.tsx @@ -0,0 +1,526 @@ +/** + * jalco-ui + * ReleaseBadge + * by Justin Levine + * ui.justinlevine.me + * + * Live GitHub release badge showing latest tag, publish date, and pre-release + * indicator. Three layouts: inline pill, horizontal row, and expanded card. + * Async server component — fetches data at build time with ISR. + * + * Props: + * - owner: GitHub username or organization + * - repo: GitHub repository name + * - layout?: "inline" | "row" | "card" (default "inline") + * - variant?: visual style variant + * - size?: badge size (inline/row only) + * - showDate?: show publish date + * - showPreRelease?: show pre-release indicator + * - showAssets?: show download asset count (card only) + * - data?: pre-fetched GitHubReleaseData to skip the API call + * + * Notes: + * - Async server component — no client JS required + * - Fetches api.github.com at build time, cached 1 hour via ISR + * - Optional GITHUB_TOKEN env var raises rate limit to 5,000 req/hr + */ + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { + fetchLatestRelease, + formatRelativeDate, + type GitHubReleaseData, +} from "@/registry/release-badge/lib/github" + +function TagIcon({ className }: { className?: string }) { + return ( + + ) +} + +function CalendarIcon({ className }: { className?: string }) { + return ( + + ) +} + +function DownloadIcon({ className }: { className?: string }) { + return ( + + ) +} + +const inlineVariants = cva( + "inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", + { + variants: { + variant: { + default: + "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground", + primary: + "rounded-md bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + secondary: + "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + outline: + "rounded-md border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + ghost: + "rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + subtle: + "rounded-full border border-border/60 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground", + }, + size: { + sm: "h-7 gap-1.5 px-2.5 text-xs [&_svg]:size-3.5", + default: "h-8 gap-2 px-3 text-sm [&_svg]:size-4", + lg: "h-9 gap-2.5 px-4 text-sm [&_svg]:size-4", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const rowVariants = cva( + "inline-flex items-center shrink-0 overflow-hidden whitespace-nowrap font-medium", + { + variants: { + variant: { + default: + "rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs", + secondary: + "rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs", + outline: + "rounded-md border border-border bg-background text-foreground shadow-xs dark:bg-input/30 dark:border-input", + ghost: "rounded-md text-muted-foreground", + subtle: + "rounded-full border border-border/60 bg-muted/40 text-muted-foreground", + }, + size: { + sm: "h-7 text-xs [&_svg]:size-3 [&>[data-segment]]:gap-1.5 [&>[data-segment]]:px-2.5", + default: + "h-8 text-sm [&_svg]:size-3.5 [&>[data-segment]]:gap-2 [&>[data-segment]]:px-3", + lg: "h-9 text-sm [&_svg]:size-4 [&>[data-segment]]:gap-2 [&>[data-segment]]:px-4", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +type InlineVariant = NonNullable["variant"]> +type RowVariant = NonNullable["variant"]> +type BadgeSize = NonNullable["size"]> + +interface ReleaseBadgeBaseProps { + /** GitHub username or organization (e.g. "vercel"). */ + owner: string + /** GitHub repository name (e.g. "next.js"). */ + repo: string + /** Show publish date. @default false for inline, true for row/card */ + showDate?: boolean + /** Show pre-release indicator. @default true */ + showPreRelease?: boolean + /** Show asset/download count (card only). @default true */ + showAssets?: boolean + /** Pre-fetched release data. When provided, skips the GitHub API call. */ + data?: GitHubReleaseData +} + +interface ReleaseBadgeInlineProps + extends ReleaseBadgeBaseProps, + Omit, "children"> { + /** @default "inline" */ + layout?: "inline" + variant?: InlineVariant + size?: BadgeSize +} + +interface ReleaseBadgeRowProps + extends ReleaseBadgeBaseProps, + Omit, "children"> { + layout: "row" + variant?: RowVariant + size?: BadgeSize +} + +interface ReleaseBadgeCardProps + extends ReleaseBadgeBaseProps, + Omit, "children"> { + layout: "card" + variant?: never + size?: never +} + +type ReleaseBadgeProps = + | ReleaseBadgeInlineProps + | ReleaseBadgeRowProps + | ReleaseBadgeCardProps + +function Divider({ + className, + variant, +}: { + className?: string + variant?: string +}) { + return ( +