diff --git a/apps/www/lib/registry.ts b/apps/www/lib/registry.ts index a13f554..f403a21 100644 --- a/apps/www/lib/registry.ts +++ b/apps/www/lib/registry.ts @@ -64,6 +64,23 @@ export function fixImport(content: string) { type: string, component: string ) => { + const blockMatch = type.match(/blocks\/([\w-]+)\/(\w+)$/) + if (blockMatch) { + const [, blockName, folder] = blockMatch + if (folder === "ui") { + return `@/components/${blockName}/ui/${component}` + } + if (folder === "components") { + return `@/components/${blockName}/components/${component}` + } + if (folder === "lib") { + return `@/components/${blockName}/lib/${component}` + } + if (folder === "hooks") { + return `@/components/${blockName}/hooks/${component}` + } + } + if (type.endsWith("components")) { return `@/components/${component}` } else if (type.endsWith("ui")) { diff --git a/apps/www/registry/collection/registry-blocks.ts b/apps/www/registry/collection/registry-blocks.ts index a9d8d67..2514b8e 100644 --- a/apps/www/registry/collection/registry-blocks.ts +++ b/apps/www/registry/collection/registry-blocks.ts @@ -10,7 +10,7 @@ export const blocks: Registry["items"] = [ "@base-ui/react", "@radix-ui/react-toggle", "zustand", - "shaka-player", + "shaka-player@^4", "lodash.clamp", ], description: "Modern seamless flat linear.app Media Player", @@ -45,11 +45,11 @@ export const blocks: Registry["items"] = [ type: "registry:component", }, { - path: `${BASE_SRC_URL}/ui/button.tsx`, - type: "registry:ui", + path: `${BASE_SRC_URL}/components/button.tsx`, + type: "registry:component", }, { - path: `${BASE_SRC_URL}/lib/media.ts`, + path: `${BASE_SRC_URL}/lib/media-kit.ts`, type: "registry:lib", }, { @@ -74,7 +74,7 @@ export const blocks: Registry["items"] = [ type: "registry:component", }, { - path: `${BASE_SRC_URL}/components/picture-in-picture-control.tsx`, + path: `${BASE_SRC_URL}/components/pip-control.tsx`, type: "registry:component", }, ], @@ -117,7 +117,7 @@ export const blocks: Registry["items"] = [ }, { author: "Rohan Gupta (@winoffrg)", - dependencies: ["@phosphor-icons/react", "zustand", "shaka-player"], + dependencies: ["@phosphor-icons/react", "zustand", "shaka-player@^4"], description: "Limeplay Basic Player", files: [ { @@ -170,13 +170,16 @@ export const blocks: Registry["items"] = [ dependencies: [ "@phosphor-icons/react", "@radix-ui/react-slot", + "async-retry", + "motion", "zustand", - "shaka-player", + "shaka-player@^4", ], description: "YouTube Music style audio player with playlist support", + devDependencies: ["@types/async-retry"], files: [ { - path: "blocks/youtube-music/lib/media.ts", + path: "blocks/youtube-music/lib/media-kit.ts", type: "registry:lib", }, { @@ -224,8 +227,8 @@ export const blocks: Registry["items"] = [ type: "registry:hook", }, { - path: "blocks/youtube-music/ui/button.tsx", - type: "registry:ui", + path: "blocks/youtube-music/components/button.tsx", + type: "registry:component", }, { path: "blocks/youtube-music/youtube-music.module.css", diff --git a/apps/www/registry/collection/registry-hooks.ts b/apps/www/registry/collection/registry-hooks.ts index 0ca4e10..fab6f61 100644 --- a/apps/www/registry/collection/registry-hooks.ts +++ b/apps/www/registry/collection/registry-hooks.ts @@ -51,7 +51,7 @@ export const hooks: Registry["items"] = [ type: "registry:hook", }, { - dependencies: ["lodash.clamp", "shaka-player", "zustand"], + dependencies: ["lodash.clamp", "shaka-player@^4", "zustand"], devDependencies: ["@types/lodash.clamp"], files: [ { @@ -72,7 +72,7 @@ export const hooks: Registry["items"] = [ type: "registry:hook", }, { - dependencies: ["shaka-player", "zustand"], + dependencies: ["shaka-player@^4", "zustand"], files: [ { path: "hooks/use-player.ts", @@ -90,7 +90,7 @@ export const hooks: Registry["items"] = [ type: "registry:hook", }, { - dependencies: ["shaka-player", "zustand"], + dependencies: ["shaka-player@^4", "zustand"], files: [ { path: "hooks/use-asset.ts", @@ -120,7 +120,7 @@ export const hooks: Registry["items"] = [ type: "registry:hook", }, { - dependencies: ["shaka-player", "zustand"], + dependencies: ["shaka-player@^4", "zustand"], files: [ { path: "hooks/use-captions.ts", diff --git a/apps/www/registry/collection/registry-ui.ts b/apps/www/registry/collection/registry-ui.ts index 612f3f2..b8f1d72 100644 --- a/apps/www/registry/collection/registry-ui.ts +++ b/apps/www/registry/collection/registry-ui.ts @@ -64,7 +64,7 @@ export const ui: Registry["items"] = [ "lp-primary-foreground": "oklch(0.985 0 0)", }, }, - dependencies: ["zustand"], + dependencies: ["zustand", "immer"], files: [ { path: "ui/media-provider.tsx", diff --git a/apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx b/apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx index 53edef3..52113f7 100644 --- a/apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/bottom-controls.tsx @@ -1,5 +1,5 @@ import { CaptionsStateControl } from "@/registry/default/blocks/linear-player/components/captions-state-control" -import { PictureInPictureControl } from "@/registry/default/blocks/linear-player/components/picture-in-picture-control" +import { PictureInPictureControl } from "@/registry/default/blocks/linear-player/components/pip-control" import { PlaybackRateControl } from "@/registry/default/blocks/linear-player/components/playback-rate-control" import { PlaybackStateControl } from "@/registry/default/blocks/linear-player/components/playback-state-control" import { Playlist } from "@/registry/default/blocks/linear-player/components/playlist" diff --git a/apps/www/registry/default/blocks/linear-player/ui/button.tsx b/apps/www/registry/default/blocks/linear-player/components/button.tsx similarity index 88% rename from apps/www/registry/default/blocks/linear-player/ui/button.tsx rename to apps/www/registry/default/blocks/linear-player/components/button.tsx index 5586f75..5493033 100644 --- a/apps/www/registry/default/blocks/linear-player/ui/button.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/button.tsx @@ -66,18 +66,24 @@ export interface ButtonProps React.ButtonHTMLAttributes, VariantProps { asChild?: boolean + render?: React.ReactElement } const Button = React.forwardRef( - ({ asChild = false, className, size, variant, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + ( + { asChild = false, children, className, render, size, variant, ...props }, + ref + ) => { + const Comp = render ? Slot : asChild ? Slot : "button" return ( + > + {render ? React.cloneElement(render, undefined, children) : children} + ) } ) diff --git a/apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx b/apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx index 8a249eb..b43af10 100644 --- a/apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/captions-state-control.tsx @@ -2,7 +2,7 @@ import { ClosedCaptioningIcon } from "@phosphor-icons/react" -import { Button } from "@/components/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { useCaptionsStore } from "@/registry/default/hooks/use-captions" import { CaptionsControl } from "@/registry/default/ui/captions" diff --git a/apps/www/registry/default/blocks/linear-player/components/media-player.tsx b/apps/www/registry/default/blocks/linear-player/components/media-player.tsx index df9c532..f29cf60 100644 --- a/apps/www/registry/default/blocks/linear-player/components/media-player.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/media-player.tsx @@ -4,7 +4,7 @@ import type { Asset } from "@/registry/default/hooks/use-asset" import { cn } from "@/lib/utils" import { BottomControls } from "@/registry/default/blocks/linear-player/components/bottom-controls" -import { MediaProvider } from "@/registry/default/blocks/linear-player/lib/media" +import { MediaProvider } from "@/registry/default/blocks/linear-player/lib/media-kit" import { CaptionsContainer } from "@/registry/default/ui/captions" import { FallbackPoster } from "@/registry/default/ui/fallback-poster" import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo" diff --git a/apps/www/registry/default/blocks/linear-player/components/picture-in-picture-control.tsx b/apps/www/registry/default/blocks/linear-player/components/pip-control.tsx similarity index 97% rename from apps/www/registry/default/blocks/linear-player/components/picture-in-picture-control.tsx rename to apps/www/registry/default/blocks/linear-player/components/pip-control.tsx index 3245a1b..7fd5d8f 100644 --- a/apps/www/registry/default/blocks/linear-player/components/picture-in-picture-control.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/pip-control.tsx @@ -2,7 +2,7 @@ import { PictureInPictureIcon } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { usePictureInPictureStore } from "@/registry/default/hooks/use-picture-in-picture" import { PictureInPictureControl as PictureInPictureControlPrimitive } from "@/registry/default/ui/picture-in-picture-control" diff --git a/apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx b/apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx index a013f89..8eb7073 100644 --- a/apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/playback-state-control.tsx @@ -7,7 +7,7 @@ import { RepeatIcon, } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { usePlaybackStore } from "@/registry/default/hooks/use-playback" import { PlaybackControl } from "@/registry/default/ui/playback-control" diff --git a/apps/www/registry/default/blocks/linear-player/components/playlist.tsx b/apps/www/registry/default/blocks/linear-player/components/playlist.tsx index cf4a30d..159d1c0 100644 --- a/apps/www/registry/default/blocks/linear-player/components/playlist.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/playlist.tsx @@ -16,7 +16,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Button } from "@/registry/default/blocks/linear-player/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { useAsset } from "@/registry/default/hooks/use-asset" import { usePlayerStore } from "@/registry/default/hooks/use-player" diff --git a/apps/www/registry/default/blocks/linear-player/components/timeline-slider-control.tsx b/apps/www/registry/default/blocks/linear-player/components/timeline-slider-control.tsx index f6b362d..77f79b5 100644 --- a/apps/www/registry/default/blocks/linear-player/components/timeline-slider-control.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/timeline-slider-control.tsx @@ -2,7 +2,7 @@ import { useState } from "react" -import { Button } from "@/registry/default/blocks/linear-player/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { usePlayerStore } from "@/registry/default/hooks/use-player" import { useTimelineStore } from "@/registry/default/hooks/use-timeline" import * as TimelineSlider from "@/registry/default/ui/timeline-control" diff --git a/apps/www/registry/default/blocks/linear-player/components/volume-state-control.tsx b/apps/www/registry/default/blocks/linear-player/components/volume-state-control.tsx index 1b41100..b00639a 100644 --- a/apps/www/registry/default/blocks/linear-player/components/volume-state-control.tsx +++ b/apps/www/registry/default/blocks/linear-player/components/volume-state-control.tsx @@ -6,7 +6,7 @@ import { SpeakerXIcon, } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { useVolumeStore } from "@/registry/default/hooks/use-volume" import { MuteControl } from "@/registry/default/ui/mute-control" diff --git a/apps/www/registry/default/blocks/linear-player/lib/media.ts b/apps/www/registry/default/blocks/linear-player/lib/media-kit.ts similarity index 100% rename from apps/www/registry/default/blocks/linear-player/lib/media.ts rename to apps/www/registry/default/blocks/linear-player/lib/media-kit.ts diff --git a/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx b/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx index fe9cfc1..e83d851 100644 --- a/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx +++ b/apps/www/registry/default/examples/picture-in-picture-control-demo.tsx @@ -2,7 +2,7 @@ import { PictureInPictureIcon } from "@phosphor-icons/react" -import { Button } from "@/registry/default/blocks/linear-player/ui/button" +import { Button } from "@/registry/default/blocks/linear-player/components/button" import { usePictureInPictureStore } from "@/registry/default/hooks/use-picture-in-picture" import { PictureInPictureControl } from "@/registry/default/ui/picture-in-picture-control" diff --git a/apps/www/registry/default/hooks/use-seek.ts b/apps/www/registry/default/hooks/use-seek.ts index a99111e..a5fd70b 100644 --- a/apps/www/registry/default/hooks/use-seek.ts +++ b/apps/www/registry/default/hooks/use-seek.ts @@ -15,12 +15,9 @@ export function useSeek() { media.currentTime = Math.max(0, Math.min(newTime, media.duration || 0)) - store.setState((state) => ({ - media: { - ...state.media, - idle: false, - }, - })) + store.setState(({ media }) => { + media.idle = false + }) } return { diff --git a/apps/www/registry/default/ui/captions.tsx b/apps/www/registry/default/ui/captions.tsx index 0a080ff..18e1ff5 100644 --- a/apps/www/registry/default/ui/captions.tsx +++ b/apps/www/registry/default/ui/captions.tsx @@ -18,6 +18,7 @@ export interface CaptionsControlProps extends React.ButtonHTMLAttributes export const CaptionsControl = React.forwardRef< @@ -43,11 +44,12 @@ export const CaptionsControl = React.forwardRef< children, disabled: userDisabled, onClick, + render, shortcut, ...restProps } = props - const Comp = asChild ? Slot : Button + const Comp = render ? Slot : asChild ? Slot : Button const handleClick = (event: React.MouseEvent) => { onClick?.(event) @@ -73,7 +75,7 @@ export const CaptionsControl = React.forwardRef< onClick={handleClick} ref={forwardedRef} > - {children} + {render ? React.cloneElement(render, undefined, children) : children} ) }) diff --git a/apps/www/registry/default/ui/mute-control.tsx b/apps/www/registry/default/ui/mute-control.tsx index 4f1d667..c6c91ae 100644 --- a/apps/www/registry/default/ui/mute-control.tsx +++ b/apps/www/registry/default/ui/mute-control.tsx @@ -10,14 +10,15 @@ import { } from "@/registry/default/hooks/use-playback" import { useVolumeStore } from "@/registry/default/hooks/use-volume" -export interface MuteControlProps extends React.ComponentProps { +export interface MuteControlProps extends React.ButtonHTMLAttributes { asChild?: boolean + render?: React.ReactElement shortcut?: string } export type MuteControlPropsDocs = Pick< MuteControlProps, - "asChild" | "shortcut" + "asChild" | "render" | "shortcut" > export const MuteControl = React.forwardRef< @@ -34,11 +35,12 @@ export const MuteControl = React.forwardRef< children, disabled: userDisabled, onClick, + render, shortcut, ...restProps } = props - const Comp = asChild ? Slot : Button + const Comp = render ? Slot : asChild ? Slot : Button const handleClick = (event: React.MouseEvent) => { onClick?.(event) @@ -65,7 +67,7 @@ export const MuteControl = React.forwardRef< onClick={handleClick} ref={forwardedRef} > - {children} + {render ? React.cloneElement(render, undefined, children) : children} ) }) diff --git a/apps/www/registry/default/ui/picture-in-picture-control.tsx b/apps/www/registry/default/ui/picture-in-picture-control.tsx index 1eed794..28181b9 100644 --- a/apps/www/registry/default/ui/picture-in-picture-control.tsx +++ b/apps/www/registry/default/ui/picture-in-picture-control.tsx @@ -10,14 +10,13 @@ import { usePlaybackStore, } from "@/registry/default/hooks/use-playback" -export interface PictureInPictureControlProps extends React.ComponentProps< - typeof Button -> { +export interface PictureInPictureControlProps extends React.ButtonHTMLAttributes { /** * Render as child component using Radix Slot * @default false */ asChild?: boolean + render?: React.ReactElement /** * Keyboard shortcut hint displayed in aria-label * @example "P" @@ -27,7 +26,7 @@ export interface PictureInPictureControlProps extends React.ComponentProps< export type PictureInPictureControlPropsDocs = Pick< PictureInPictureControlProps, - "asChild" | "shortcut" + "asChild" | "render" | "shortcut" > export const PictureInPictureControl = React.forwardRef< @@ -49,11 +48,12 @@ export const PictureInPictureControl = React.forwardRef< children, disabled: userDisabled, onClick, + render, shortcut, ...restProps } = props - const Comp = asChild ? Slot : Button + const Comp = render ? Slot : asChild ? Slot : Button const handleClick = async (event: React.MouseEvent) => { onClick?.(event) @@ -85,7 +85,7 @@ export const PictureInPictureControl = React.forwardRef< onClick={handleClick} ref={forwardedRef} > - {children} + {render ? React.cloneElement(render, undefined, children) : children} ) }) diff --git a/apps/www/registry/default/ui/playback-control.tsx b/apps/www/registry/default/ui/playback-control.tsx index b673a45..6098915 100644 --- a/apps/www/registry/default/ui/playback-control.tsx +++ b/apps/www/registry/default/ui/playback-control.tsx @@ -9,12 +9,13 @@ import { usePlaybackStore, } from "@/registry/default/hooks/use-playback" -interface PlaybackControlProps extends React.ComponentProps { +interface PlaybackControlProps extends React.ButtonHTMLAttributes { /** * Render as child component using Radix Slot * @default false */ asChild?: boolean + render?: React.ReactElement /** * Keyboard shortcut hint displayed in aria-label * @example "Space" @@ -36,11 +37,12 @@ export const PlaybackControl = React.forwardRef< children, disabled: userDisabled, onClick, + render, shortcut, ...restProps } = props - const Comp = asChild ? Slot : Button + const Comp = render ? Slot : asChild ? Slot : Button const handleClick = (event: React.MouseEvent) => { onClick?.(event) @@ -79,7 +81,7 @@ export const PlaybackControl = React.forwardRef< onClick={handleClick} ref={forwardedRef} > - {children} + {render ? React.cloneElement(render, undefined, children) : children} ) }) diff --git a/apps/www/registry/pro b/apps/www/registry/pro index ab6ea24..fb1f7a5 160000 --- a/apps/www/registry/pro +++ b/apps/www/registry/pro @@ -1 +1 @@ -Subproject commit ab6ea243306bc0311a9c3cf1c0daaf614eb6e582 +Subproject commit fb1f7a5036a670398b8a8c8c86c59a1a92c38cc4 diff --git a/apps/www/scripts/validate-registries.ts b/apps/www/scripts/validate-registries.ts index 6ae83df..bd287d1 100644 --- a/apps/www/scripts/validate-registries.ts +++ b/apps/www/scripts/validate-registries.ts @@ -16,6 +16,7 @@ */ import { promises as fs } from "fs" +import { globSync } from "glob" import path from "path" import { registryItemSchema, registrySchema } from "shadcn/schema" @@ -95,6 +96,7 @@ async function main() { const individualItems = await validateIndividualJsonFiles() await crossReferenceItems(registryData, individualItems) await validateDependencyResolution(registryData) + await validateImportCompleteness() await validateTierRegistries() // Summary @@ -143,8 +145,57 @@ async function validateBuiltRegistryJson() { return result.data } +const SHADCN_REGISTRY_URL = "https://ui.shadcn.com/r/styles/default" + +/** Extract common block path prefix from the first file entry, e.g. "blocks/youtube-music" */ +function getBlockPrefix(files: Array<{ path: string }>): null | string { + const first = files[0]?.path + if (!first) return null + const match = /^(blocks\/[^/]+)\//.exec(first) + return match ? match[1] : null +} + +async function resolveFromShadcn(name: string): Promise { + try { + const res = await fetch(`${SHADCN_REGISTRY_URL}/${name}.json`, { + method: "HEAD", + }) + return res.ok + } catch { + return false + } +} + +/** + * Recursively resolve all transitive registryDependencies for a set of + * direct dependency names. + */ +function resolveTransitiveDeps(directDeps: string[]): Set { + const itemMap = new Map() + for (const item of registryCollection.items) { + itemMap.set( + item.name, + (item.registryDependencies as string[] | undefined) ?? [] + ) + } + + const resolved = new Set() + const stack = [...directDeps] + while (stack.length > 0) { + const name = stack.pop()! + if (resolved.has(name)) continue + resolved.add(name) + const children = itemMap.get(name) + if (children) { + stack.push(...children) + } + } + return resolved +} + /** * 5. Validate registryDependencies resolve to existing items. + * Unresolved deps are checked against shadcn's public registry. */ async function validateDependencyResolution( registryData: null | { @@ -159,19 +210,17 @@ async function validateDependencyResolution( console.log("\nšŸ”— Validating dependency resolution...") const allNames = new Set(registryData.items.map((item) => item.name)) + const shadcnCache = new Map() let missingCount = 0 for (const item of registryData.items) { if (!item.registryDependencies?.length) continue for (const dep of item.registryDependencies) { - // URL dependencies - extract the name and check the JSON exists if (dep.startsWith("http")) { const depName = dep.split("/").pop()?.replace(".json", "") if (depName && !allNames.has(depName)) { - // Check if it's an external dependency (shadcn, etc.) if (!dep.includes("limeplay")) { - // External dependency - skip validation continue } warn( @@ -182,8 +231,14 @@ async function validateDependencyResolution( continue } - // Non-URL dependencies if (!dep.startsWith("@") && !allNames.has(dep)) { + if (!shadcnCache.has(dep)) { + shadcnCache.set(dep, await resolveFromShadcn(dep)) + } + if (shadcnCache.get(dep)) { + continue + } + warn(`"${item.name}" depends on "${dep}" which is not in the registry`) missingCount++ } @@ -195,6 +250,114 @@ async function validateDependencyResolution( } } +async function validateImportCompleteness() { + console.log("\nšŸ”Ž Validating import completeness...") + + const REGISTRY_DEFAULT_DIR = path.join(process.cwd(), "registry", "default") + + const allItemNames = new Set( + registryCollection.items.map((item) => item.name) + ) + + const IMPORT_PATTERNS: Array<{ nameGroup: number; regex: RegExp }> = [ + { + nameGroup: 1, + regex: /from\s+["']@\/registry\/default\/hooks\/([^"'/]+)["']/, + }, + { + nameGroup: 1, + regex: /from\s+["']@\/registry\/default\/ui\/([^"'/]+)["']/, + }, + { + nameGroup: 1, + regex: /from\s+["']@\/registry\/default\/lib\/([^"'/]+)["']/, + }, + { nameGroup: 1, regex: /from\s+["']@\/hooks\/limeplay\/([^"'/]+)["']/ }, + { + nameGroup: 1, + regex: /from\s+["']@\/components\/limeplay\/([^"'/]+)["']/, + }, + ] + + let errorTotal = 0 + + for (const item of registryCollection.items) { + if (item.type !== "registry:block") continue + if (!item.files?.length) continue + + const directDeps = (item.registryDependencies as string[] | undefined) ?? [] + const declared = new Set(directDeps) + const transitive = resolveTransitiveDeps(directDeps) + const blockPrefix = getBlockPrefix(item.files as Array<{ path: string }>) + + const sourceFiles: string[] = [] + for (const f of item.files as Array<{ path: string }>) { + const abs = path.join(REGISTRY_DEFAULT_DIR, f.path) + const matches = globSync(abs) + sourceFiles.push(...matches) + } + + const missing: Array<{ + dep: string + file: string + transitivelyCovered: boolean + }> = [] + + for (const srcFile of sourceFiles) { + let content: string + try { + content = await fs.readFile(srcFile, "utf-8") + } catch { + continue + } + + for (const line of content.split("\n")) { + for (const { nameGroup, regex } of IMPORT_PATTERNS) { + const m = regex.exec(line) + if (!m) continue + const depName = m[nameGroup] + + if (blockPrefix && line.includes(blockPrefix)) continue + if (!allItemNames.has(depName)) continue + + if (!declared.has(depName)) { + missing.push({ + dep: depName, + file: path.relative(REGISTRY_DEFAULT_DIR, srcFile), + transitivelyCovered: transitive.has(depName), + }) + } + } + } + } + + if (missing.length > 0) { + for (const { dep, file, transitivelyCovered } of missing) { + if (transitivelyCovered) { + // Covered transitively — won't break install, but not declared directly + warn( + `"${item.name}" imports "${dep}" (in ${file}) — resolved transitively but not in direct registryDependencies` + ) + } else { + // Not covered at all — this WILL break install + error( + `"${item.name}" imports "${dep}" (in ${file}) but it is NOT in registryDependencies (even transitively)` + ) + errorTotal++ + } + } + } + } + + if (errorTotal === 0 && warningCount === 0) { + pass("All block imports are covered by registryDependencies") + } else if (errorTotal === 0) { + pass( + "All block imports resolve (some only transitively — see warnings above)" + ) + } +} + /** * 3. Validate each individual component JSON file in public/r/. */ @@ -273,7 +436,7 @@ async function validateRegistryCollection() { } /** - * 6. Validate tier-specific registries (free/pro). + * 7. Validate tier-specific registries (free/pro). */ async function validateTierRegistries() { for (const tier of TIERS) { diff --git a/prompts/code-rabbit.md b/prompts/code-rabbit.md index 55aaa19..859fa1e 100644 --- a/prompts/code-rabbit.md +++ b/prompts/code-rabbit.md @@ -38,6 +38,9 @@ Flag PRs where: * Registry entries are missing * Dependencies are incorrect * Public APIs are undocumented +* Block files in `lib/`, `ui/`, `hooks/` folders have filenames matching any registry item in the dependency tree — the shadcn CLI rewrites those imports to the registry item's target path instead of the block's file. Use `components/` folder (always block-scoped) or a non-colliding filename. +* Dependencies with breaking major versions are unpinned (e.g. `"shaka-player"` instead of `"shaka-player@^4"`) +* Control primitives support `asChild` but not `render?: React.ReactElement` — the b0 shadcn preset transforms `asChild` to `render` at install time, so primitives must accept both --- @@ -86,6 +89,24 @@ Flag these patterns: * **Retry exhaustion without fallthrough** — Any retry loop must have a path for when retries are spent; it must not hang. * **Split-owner mutable state** — If two APIs write the same shared state, one must be canonical and the other must document that it bypasses tracking. +### Zustand + Immer Middleware Safety + +Flag and block these patterns in Zustand stores that use Immer middleware: + +* Reassigning the producer argument (e.g. `state = nextState`) instead of mutating draft or returning new state. +* Returning `undefined` from a producer. +* Mutating class instances without `[immerable] = true`. +* Mutating exotic/non-draftable objects in draft updates. +* Creating non-tree state (circular refs or one object referenced in multiple branches). +* Mutating external payload objects after assigning them into draft state. +* Assuming Immer-generated patches are minimal/optimal in patch-sensitive logic. +* Nested `produce` calls where the inner result is not used. +* Equality logic that depends on draft referential identity (`===`, `indexOf` against draft values). +* Array mutations outside numeric indices or `length`. +* With `enableArrayMethods()`, mutating callback parameters of overridden methods (`filter`, `find`, `some`, `every`, `slice`) as if they were drafts. + +If these are present, mark as correctness risk because Zustand subscriptions can be skipped or state can be mutated outside tracking. + --- ### Error Handling diff --git a/prompts/github-copilot.md b/prompts/github-copilot.md index fe9107a..57f67d2 100644 --- a/prompts/github-copilot.md +++ b/prompts/github-copilot.md @@ -45,6 +45,7 @@ Limeplay is a shadcn/ui-based, headless, composable media player UI library buil #### Component Design * āŒ Missing `asChild` support via `@radix-ui/react-slot` +* āŒ Control primitives that accept `asChild` but not `render?: React.ReactElement` — the b0 preset CLI transforms `asChild` to `render` during install, so both must be supported * āŒ Blocking event propagation * āŒ Forced prop overrides @@ -53,6 +54,8 @@ Limeplay is a shadcn/ui-based, headless, composable media player UI library buil * āŒ New or modified components/hooks without registry updates * āŒ Missing dependencies in registry metadata * āŒ CLI install must not break +* āŒ Block files in `lib/`, `ui/`, `hooks/` whose filename (without extension) matches any registry item in the dependency tree (shadcn CLI hijacks the import) — use `components/` folder or a non-colliding name +* āŒ Unpinned dependencies with breaking major versions (e.g. `"shaka-player"` instead of `"shaka-player@^4"`) #### Imports @@ -70,6 +73,22 @@ Limeplay is a shadcn/ui-based, headless, composable media player UI library buil * āŒ Retry loops without a fallthrough path when retries are exhausted * āŒ Two APIs writing the same shared mutable state without a documented single owner +#### Zustand + Immer Correctness (BLOCKER) + +* āŒ Reassigning the Immer recipe argument inside `set((state) => ...)` (e.g. `state = nextState`) +* āŒ Returning `undefined` from an Immer producer (ambiguous no-op) +* āŒ Mutating class instances in store state without `[immerable] = true` (can break Zustand subscriptions) +* āŒ Mutating non-draftable or exotic objects (e.g. `window.location`, non-immerable class instances) +* āŒ Introducing circular references or shared object identity in multiple branches of state +* āŒ Mutating payload objects from outside state after assigning into draft (mutates source object unexpectedly) +* āŒ Assuming Immer patch output is minimal/optimal when implementing patch-based logic +* āŒ Nested `produce`/Immer calls whose returned value is ignored +* āŒ Equality checks that rely on draft referential identity (`===` / `indexOf` on draft values) +* āŒ Mutating array custom properties in Immer (only indices and `length` are tracked) +* āŒ With `enableArrayMethods()`: mutating items inside callback params of overridden methods (`filter`/`find`/`some`/`every`/`slice`) as if they were drafts + +When any of the above appears in Zustand store updates, block the PR. + #### Accessibility * āŒ Missing ARIA attributes diff --git a/prompts/ide.md b/prompts/ide.md index 1773641..c9b7d82 100644 --- a/prompts/ide.md +++ b/prompts/ide.md @@ -69,6 +69,24 @@ Limeplay uses a **feature-based composition** system via `createMediaKit`. * Use `React.memo`, `useCallback`, and `useMemo` for high-frequency components * Never introduce unnecessary re-renders +### Zustand + Immer Non-Negotiables + +When updating store state with `zustand/middleware/immer`: + +* In `set((state) => { ... })`, mutate draft fields directly. Never reassign `state`. +* If replacing state, return a new value explicitly. Never return `undefined`. +* Never mutate class instances unless they are explicitly immerable (`[immerable] = true`). +* Never mutate exotic/non-draftable objects inside producers. +* Keep state a strict unidirectional tree (no circular refs, no shared object identity across branches). +* Treat external payloads as external: clone/sanitize before storing if they might be mutated. +* Do not assume Immer patches are minimal/optimal if implementing patch-dependent workflows. +* Avoid nested `produce`; if used, always apply the returned result. +* Do not rely on draft referential equality (`===` / `indexOf` against draft values). Compare by id or outside producer. +* For arrays, only mutate numeric indices and `length`. +* If `enableArrayMethods()` is enabled, callbacks of overridden methods (`filter`, `find`, `some`, `every`, `slice`) receive base values, not drafts. + +If a store update violates any of these rules, stop and fix it before continuing. + --- ## Component Design Rules @@ -132,6 +150,61 @@ You are **assisting**, not completing the feature end-to-end. --- +### shadcn CLI Limitations (CRITICAL — Registry Authoring) + +The `shadcn add` CLI has an import rewriting system that can break block-internal imports. Understand these rules when authoring registry items: + +#### Import Name Collision + +When `shadcn add` installs a registry item, it rewrites import paths by matching **filenames** against all registry items in scope (your own registryDependencies + shadcn built-ins like `button`, `toggle`, `select`). If a block file's name matches any registry item that's being installed as a dependency, the CLI hijacks the import to point at that registry item's target path instead of your block's file. + +**Rule:** Block-internal files MUST have unique names that don't collide with any registry item name in the dependency tree. + +* āŒ `blocks/linear-player/lib/media.ts` — "media" is a limeplay registry item (registryDependency) → import gets hijacked to its target +* āŒ `blocks/linear-player/ui/button.tsx` — "button" is a shadcn built-in registry item → import gets hijacked +* āŒ `blocks/linear-player/components/picture-in-picture-control.tsx` — same name as limeplay primitive → hijacked +* āœ… `blocks/linear-player/components/media-kit.ts` — "media-kit" doesn't match any registry item +* āœ… `blocks/linear-player/components/pip-control.tsx` — "pip-control" doesn't match any registry item +* āœ… `blocks/linear-player/components/button.tsx` — in `components/` folder, CLI scopes it to the block +* āœ… `blocks/linear-player/lib/test-util.ts` — "test-util" doesn't match any registry item + +#### Folder Scoping in Blocks + +The CLI treats `components/` inside a block as block-scoped — imports always resolve correctly regardless of filename collisions. Files in `ui/`, `lib/`, `hooks/` within a block are vulnerable to name collision hijacking. + +**Rule:** Prefer `components/` for all block-internal files. Only use `lib/`, `ui/`, `hooks/` if the filename is guaranteed unique across all registry items in the dependency tree. + +File **installation** (target path) always works correctly via PATH_MAPPINGS. The problem is only with **import string rewriting** in consuming files. + +#### `asChild` → `render` Transformation (b0 preset) + +The b0 shadcn preset uses Base UI instead of Radix. During `shadcn add`, the CLI transforms `asChild` patterns to Base UI's `render` prop pattern: + +```tsx +// Source (what you write): + + + + +// Installed (b0 preset transforms to): +}>... +``` + +**Rule:** All control primitives that support `asChild` MUST also accept a `render?: React.ReactElement` prop. Use `Slot` for both — when `render` is provided, clone the render element with children. + +#### Version Pinning + +Always pin dependencies that have breaking changes across majors: +* āœ… `"shaka-player@^4"` — v5 removed APIs we use +* āŒ `"shaka-player"` — installs latest (v5), breaks types + +#### Related upstream issues + +* [shadcn-ui/ui#8196](https://github.com/shadcn-ui/ui/issues/8196) — blocks alias in import transformation +* [shadcn-ui/ui#9481](https://github.com/shadcn-ui/ui/issues/9481) — registry:build loses file type and target + +--- + ### Phase 2 — Registry (ASK FIRST) Once feature development is complete: