diff --git a/apps/cli/README.md b/apps/cli/README.md index a27a683..0682629 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ > CLI for installing Watermelon React Native components -Beautiful, accessible UI components for React Native and Expo applications. Install components with a single command! +Beautiful, accessible UI components for React Native and Expo applications. Install components from one or more shadcn-style registries with a single command. ## Installation @@ -18,7 +18,8 @@ watermelon init # Add components watermelon add button -watermelon add text +watermelon add @watermelon/button +watermelon add @aceternity/card ``` ## Commands @@ -27,16 +28,93 @@ watermelon add text Initialize Watermelon in your React Native/Expo project. -### `watermelon add ` +### `watermelon add ` Add one or more components to your project. +Examples: + +```bash +watermelon add button +watermelon add @watermelon/button +watermelon add @aceternity/card --dry-run +watermelon add button --path src +watermelon add button --force +``` + +Flags: + +- `--force` overwrite existing files +- `--path ` install into a custom directory +- `--dry-run` preview dependencies and file changes + +## Configuration + +Watermelon stores project config in `watermelon.json`. + +```json +{ + "registries": { + "@watermelon": { + "name": "@watermelon", + "homepage": "https://registry.watermelon.dev", + "url": "https://registry.watermelon.dev/{name}.json", + "description": "Official Watermelon component registry." + }, + "@aceternity": { + "name": "@aceternity", + "homepage": "https://aceternity.com", + "url": "https://aceternity.com/r/{name}.json", + "description": "Aceternity UI component registry." + } + }, + "defaultRegistry": "https://registry.watermelon.dev/{name}.json" +} +``` + +See [examples/watermelon.json](./examples/watermelon.json) for a complete example. + +## Registry Format + +Any registry can work if it serves: + +- `/.json` +- `/files/` + +It can also use a shadcn-style directory entry with a URL template such as: + +```json +{ + "name": "@8bitcn", + "homepage": "https://www.8bitcn.com", + "url": "https://www.8bitcn.com/r/{name}.json", + "description": "A set of 8-bit styled retro components." +} +``` + +Example manifest: + +```json +{ + "name": "button", + "dependencies": ["clsx"], + "files": [ + { + "path": "components/ui/button.tsx", + "url": "https://registry.watermelon.dev/files/components/ui/button.tsx" + } + ] +} +``` + +See [examples/button.json](./examples/button.json) for a full example. + ## Features - ๐Ÿš€ **Fast Installation** - Install components in seconds -- ๐Ÿ“ฆ **Auto Dependencies** - Automatically installs required packages -- ๐ŸŽจ **Customizable** - Tailwind-based styling with NativeWind -- ๐Ÿ“ฑ **Cross-Platform** - Works on iOS, Android, and Web +- ๐Ÿ“ฆ **Auto Dependencies** - Automatically installs only missing packages +- ๐ŸŒ **Multi-Registry** - Resolve scoped components across different registries +- ๐Ÿงช **Dry Runs** - Preview file changes before writing anything - ๐Ÿ”ง **Type-Safe** - Full TypeScript support ## Available Components diff --git a/apps/cli/dist/index.js b/apps/cli/dist/index.js index aaaafed..8d4939c 100755 --- a/apps/cli/dist/index.js +++ b/apps/cli/dist/index.js @@ -16,16 +16,16 @@ const program = new commander_1.Command(); program .name('watermelon') .description('CLI for Watermelon Design System') - .version('0.0.1'); + .version('0.0.2'); program .command('init') .description('Initialize Watermelon in your project') .action(async () => { - console.log(chalk_1.default.bold.cyan('๐Ÿ‰ Welcome to Watermelon!\n')); + console.log(chalk_1.default.bold.cyan('Watermelon setup\n')); const cwd = process.cwd(); const existingConfig = await (0, config_js_1.getConfig)(cwd); if (existingConfig) { - console.log(chalk_1.default.yellow('โš ๏ธ Watermelon is already initialized in this project.')); + console.log(chalk_1.default.yellow('Watermelon is already initialized in this project.')); const { overwrite } = await (0, prompts_1.default)({ type: 'confirm', name: 'overwrite', @@ -77,13 +77,9 @@ program }; const spinner = (0, ora_1.default)('Setting up project...').start(); try { - // Save config await (0, config_js_1.setConfig)(cwd, config); - // Create directories await (0, files_js_1.ensureComponentsDirectory)(cwd, config); - // Create utils file await (0, files_js_1.ensureUtilsFile)(cwd, config); - // Install required dependencies const requiredDeps = [ 'clsx', 'tailwind-merge', @@ -95,97 +91,99 @@ program spinner.text = 'Installing dependencies...'; await (0, dependencies_js_1.installDependencies)(missingDeps, cwd); } - spinner.succeed(chalk_1.default.green('โœ“ Watermelon initialized successfully!')); - console.log('\n' + chalk_1.default.bold('Next steps:')); - console.log(chalk_1.default.gray(' 1. Add components: ') + chalk_1.default.cyan('watermelon add button')); - console.log(chalk_1.default.gray(' 2. Import in your app: ') + chalk_1.default.cyan('import { Button } from "@/components/ui/button"')); - console.log(''); + spinner.succeed(chalk_1.default.green('Watermelon initialized successfully.')); + console.log(`\n${chalk_1.default.bold('Next steps')}`); + console.log(` ${chalk_1.default.gray('Add a component:')} ${chalk_1.default.cyan('watermelon add button')}`); + console.log(` ${chalk_1.default.gray('Use a namespace:')} ${chalk_1.default.cyan('watermelon add @aceternity/card')}`); } catch (error) { spinner.fail('Failed to initialize Watermelon'); - console.error(chalk_1.default.red(error instanceof Error ? error.message : String(error))); + console.error(chalk_1.default.red(getErrorMessage(error))); process.exit(1); } }); program .command('add') - .description('Add components to your project') - .argument('[components...]', 'components to add') - .action(async (components) => { + .description('Add components from one or more registries') + .argument('', 'components to add') + .option('-f, --force', 'overwrite existing files') + .option('-p, --path ', 'custom install directory') + .option('--dry-run', 'preview changes without writing files or installing dependencies') + .action(async (components, options) => { const cwd = process.cwd(); const config = await (0, config_js_1.getConfig)(cwd); if (!config) { - console.log(chalk_1.default.red('โœ— Watermelon is not initialized in this project.')); - console.log(chalk_1.default.gray(' Run ') + chalk_1.default.cyan('watermelon init') + chalk_1.default.gray(' first.')); - return; - } - if (!components || components.length === 0) { - console.log(chalk_1.default.red('โœ— Please specify at least one component.')); - console.log(chalk_1.default.gray(' Example: ') + chalk_1.default.cyan('watermelon add button')); - return; + console.log(chalk_1.default.red('Watermelon is not initialized in this project.')); + console.log(`${chalk_1.default.gray('Run')} ${chalk_1.default.cyan('watermelon init')} ${chalk_1.default.gray('first.')}`); + process.exit(1); } - const spinner = (0, ora_1.default)('Fetching registry...').start(); + const spinner = (0, ora_1.default)('Resolving component manifests...').start(); try { - const registry = await (0, registry_js_1.fetchRegistry)(); - spinner.succeed('Registry fetched'); - // Validate all components exist - for (const componentName of components) { - if (!registry.components[componentName]) { - console.log(chalk_1.default.red(`โœ— Component "${componentName}" not found in registry.`)); - console.log(chalk_1.default.gray(' Available components: ') + - Object.keys(registry.components).join(', ')); - return; - } + const resolvedComponents = await (0, registry_js_1.collectComponents)(components, config); + spinner.succeed(`Resolved ${resolvedComponents.length} component${resolvedComponents.length === 1 ? '' : 's'}.`); + const dependencyNames = Array.from(new Set(resolvedComponents.flatMap((component) => component.manifest.dependencies ?? []))).sort(); + const missingDependencies = await (0, dependencies_js_1.getMissingDependencies)(dependencyNames, cwd); + if (options.dryRun) { + printDryRunSummary(resolvedComponents, missingDependencies, options.path); } - // Get all dependencies - const allComponents = new Set(); - for (const componentName of components) { - const deps = (0, registry_js_1.getAllComponentDependencies)(componentName, registry); - deps.forEach(dep => allComponents.add(dep.name)); + else if (missingDependencies.length > 0) { + await (0, dependencies_js_1.installDependencies)(missingDependencies, cwd); } - console.log(chalk_1.default.bold(`\nComponents to install: `) + - Array.from(allComponents).join(', ')); - // Collect all npm dependencies - const allNpmDeps = new Set(); - for (const componentName of allComponents) { - const component = registry.components[componentName]; - if (component.dependencies) { - component.dependencies.forEach(dep => allNpmDeps.add(dep)); - } + const fileSpinner = (0, ora_1.default)(options.dryRun ? 'Planning file changes...' : 'Installing component files...').start(); + const plannedFiles = await (0, files_js_1.installFiles)(resolvedComponents, { + cwd, + config, + installPath: options.path, + force: options.force, + dryRun: options.dryRun, + downloadFile: (component, file) => (0, registry_js_1.downloadRegistryFile)(component.specifier.fileBaseUrl, file), + }); + fileSpinner.succeed(options.dryRun + ? `Planned ${plannedFiles.length} file change${plannedFiles.length === 1 ? '' : 's'}.` + : `Installed ${plannedFiles.length} file${plannedFiles.length === 1 ? '' : 's'}.`); + if (options.dryRun) { + printDryRunFiles(plannedFiles); + return; } - // Check for missing dependencies - const missingDeps = await (0, dependencies_js_1.getMissingDependencies)(Array.from(allNpmDeps), cwd); - if (missingDeps.length > 0) { - console.log(chalk_1.default.yellow(`\n๐Ÿ“ฆ Installing ${missingDeps.length} dependencies...\n`)); - await (0, dependencies_js_1.installDependencies)(missingDeps, cwd); + console.log(`\n${chalk_1.default.bold.green('Installed components')}`); + for (const component of resolvedComponents) { + console.log(` ${chalk_1.default.cyan(component.specifier.raw)}`); } - // Install components - console.log(chalk_1.default.bold('\n๐Ÿ“ Installing components...\n')); - for (const componentName of allComponents) { - const componentSpinner = (0, ora_1.default)(`Installing ${componentName}...`).start(); - const component = registry.components[componentName]; - try { - for (const file of component.files) { - // Fetch the component file content - const content = await (0, registry_js_1.fetchComponentFile)(file.path); - // Transform imports to use user's aliases - const transformedContent = (0, files_js_1.transformImports)(content, config); - // Write to disk - await (0, files_js_1.writeComponent)(file.path, transformedContent, cwd); - } - componentSpinner.succeed(chalk_1.default.green(`${componentName} installed`)); - } - catch (error) { - componentSpinner.fail(chalk_1.default.red(`Failed to install ${componentName}`)); - throw error; - } + if (missingDependencies.length > 0) { + console.log(`\n${chalk_1.default.bold('Installed dependencies')}`); + console.log(` ${missingDependencies.join(', ')}`); } - console.log(chalk_1.default.bold.green('\nโœ“ All components installed successfully!\n')); } catch (error) { spinner.fail('Failed to add components'); - console.error(chalk_1.default.red(error instanceof Error ? error.message : String(error))); + console.error(chalk_1.default.red(getErrorMessage(error))); process.exit(1); } }); program.parse(); +function printDryRunSummary(components, missingDependencies, installPath) { + console.log(`\n${chalk_1.default.bold('Dry run')}`); + console.log(` ${chalk_1.default.gray('Components:')} ${components.map((component) => component.specifier.raw).join(', ')}`); + if (installPath) { + console.log(` ${chalk_1.default.gray('Install path:')} ${installPath}`); + } + if (missingDependencies.length > 0) { + console.log(` ${chalk_1.default.gray('Missing dependencies:')} ${missingDependencies.join(', ')}`); + } + else { + console.log(` ${chalk_1.default.gray('Missing dependencies:')} none`); + } +} +function printDryRunFiles(plannedFiles) { + if (plannedFiles.length === 0) { + return; + } + console.log(`\n${chalk_1.default.bold('Files')}`); + for (const file of plannedFiles) { + const label = file.exists ? chalk_1.default.yellow('overwrite') : chalk_1.default.green('create'); + console.log(` ${label} ${file.relativePath}`); + } +} +function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/cli/dist/utils/config.js b/apps/cli/dist/utils/config.js index 3f67643..b683828 100644 --- a/apps/cli/dist/utils/config.js +++ b/apps/cli/dist/utils/config.js @@ -3,12 +3,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.REGISTRY_URL = exports.DEFAULT_CONFIG = void 0; +exports.DEFAULT_CONFIG = exports.DEFAULT_REGISTRIES = void 0; exports.getConfig = getConfig; exports.setConfig = setConfig; +exports.mergeConfig = mergeConfig; +exports.isRegistryDirectoryEntry = isRegistryDirectoryEntry; exports.resolveImport = resolveImport; const fs_extra_1 = __importDefault(require("fs-extra")); const path_1 = __importDefault(require("path")); +exports.DEFAULT_REGISTRIES = { + '@watermelon': { + name: '@watermelon', + homepage: 'https://registry.watermelon.dev', + url: 'https://registry.watermelon.dev/{name}.json', + description: 'Official Watermelon component registry.', + }, + '@aceternity': { + name: '@aceternity', + homepage: 'https://aceternity.com', + url: 'https://aceternity.com/registry/{name}.json', + description: 'Aceternity component registry.', + }, +}; exports.DEFAULT_CONFIG = { style: 'default', tailwind: { @@ -20,26 +36,50 @@ exports.DEFAULT_CONFIG = { components: '@/components', utils: '@/lib/utils', }, + registries: exports.DEFAULT_REGISTRIES, + defaultRegistry: 'https://registry.watermelon.dev/{name}.json', }; -exports.REGISTRY_URL = 'https://raw.githubusercontent.com/vanshpatelx/RN/main/packages/registry'; +const CONFIG_FILE = 'watermelon.json'; async function getConfig(cwd) { try { - const configPath = path_1.default.join(cwd, 'watermelon.json'); + const configPath = path_1.default.join(cwd, CONFIG_FILE); const configExists = await fs_extra_1.default.pathExists(configPath); if (!configExists) { return null; } const config = await fs_extra_1.default.readJson(configPath); - return config; + return mergeConfig(config); } - catch (error) { + catch { return null; } } async function setConfig(cwd, config) { - const configPath = path_1.default.join(cwd, 'watermelon.json'); + const configPath = path_1.default.join(cwd, CONFIG_FILE); await fs_extra_1.default.writeJson(configPath, config, { spaces: 2 }); } +function mergeConfig(config) { + return { + ...exports.DEFAULT_CONFIG, + ...config, + tailwind: { + ...exports.DEFAULT_CONFIG.tailwind, + ...config.tailwind, + }, + aliases: { + ...exports.DEFAULT_CONFIG.aliases, + ...config.aliases, + }, + registries: { + ...exports.DEFAULT_CONFIG.registries, + ...config.registries, + }, + defaultRegistry: config.defaultRegistry ?? exports.DEFAULT_CONFIG.defaultRegistry, + }; +} +function isRegistryDirectoryEntry(value) { + return typeof value === 'object' && value !== null && 'url' in value && 'name' in value; +} function resolveImport(alias, config) { if (alias === '@/components') { return config.aliases.components; diff --git a/apps/cli/dist/utils/files.js b/apps/cli/dist/utils/files.js index 4798faf..5d63b9c 100644 --- a/apps/cli/dist/utils/files.js +++ b/apps/cli/dist/utils/files.js @@ -3,20 +3,40 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.writeComponent = writeComponent; +exports.installFiles = installFiles; exports.transformImports = transformImports; exports.ensureUtilsFile = ensureUtilsFile; exports.ensureComponentsDirectory = ensureComponentsDirectory; const fs_extra_1 = __importDefault(require("fs-extra")); const path_1 = __importDefault(require("path")); -async function writeComponent(targetPath, content, cwd) { - const fullPath = path_1.default.join(cwd, targetPath); - await fs_extra_1.default.ensureDir(path_1.default.dirname(fullPath)); - await fs_extra_1.default.writeFile(fullPath, content, 'utf-8'); +async function installFiles(components, options) { + const plannedFiles = []; + const targetRoot = resolveTargetRoot(options.cwd, options.installPath); + for (const component of components) { + for (const file of component.manifest.files) { + const targetPath = path_1.default.join(targetRoot, file.path); + const exists = await fs_extra_1.default.pathExists(targetPath); + if (exists && !options.force) { + throw new Error(`File already exists: ${path_1.default.relative(options.cwd, targetPath)}. Re-run with --force to overwrite.`); + } + plannedFiles.push({ + targetPath, + relativePath: path_1.default.relative(options.cwd, targetPath), + exists, + }); + if (options.dryRun) { + continue; + } + const content = await options.downloadFile(component, file); + const transformedContent = transformImports(content, options.config); + await fs_extra_1.default.ensureDir(path_1.default.dirname(targetPath)); + await fs_extra_1.default.writeFile(targetPath, transformedContent, 'utf-8'); + } + } + return plannedFiles; } function transformImports(content, config) { let transformed = content; - // Transform @/registry/* imports to @/components/* transformed = transformed.replace(/@\/registry\/components/g, config.aliases.components); transformed = transformed.replace(/@\/registry\/lib/g, path_1.default.dirname(config.aliases.utils)); return transformed; @@ -31,14 +51,20 @@ export function cn(...inputs: ClassValue[]) { `; const utilsPath = config.aliases.utils.replace('@/', ''); const fullPath = path_1.default.join(cwd, utilsPath + '.ts'); - const exists = await fs_extra_1.default.pathExists(fullPath); - if (!exists) { + if (!(await fs_extra_1.default.pathExists(fullPath))) { await fs_extra_1.default.ensureDir(path_1.default.dirname(fullPath)); await fs_extra_1.default.writeFile(fullPath, utilsContent, 'utf-8'); } } async function ensureComponentsDirectory(cwd, config) { const componentsPath = config.aliases.components.replace('@/', ''); - const uiPath = path_1.default.join(cwd, componentsPath, 'ui'); - await fs_extra_1.default.ensureDir(uiPath); + await fs_extra_1.default.ensureDir(path_1.default.join(cwd, componentsPath, 'ui')); +} +function resolveTargetRoot(cwd, installPath) { + if (!installPath) { + return cwd; + } + return path_1.default.isAbsolute(installPath) + ? installPath + : path_1.default.join(cwd, installPath); } diff --git a/apps/cli/dist/utils/registry.js b/apps/cli/dist/utils/registry.js index 889a384..67db4c5 100644 --- a/apps/cli/dist/utils/registry.js +++ b/apps/cli/dist/utils/registry.js @@ -3,56 +3,177 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.fetchRegistry = fetchRegistry; -exports.fetchComponentFile = fetchComponentFile; -exports.getComponentInfo = getComponentInfo; -exports.getAllComponentDependencies = getAllComponentDependencies; +exports.parseComponentSpecifier = parseComponentSpecifier; +exports.resolveRegistry = resolveRegistry; +exports.fetchComponent = fetchComponent; +exports.collectComponents = collectComponents; +exports.downloadRegistryFile = downloadRegistryFile; const node_fetch_1 = __importDefault(require("node-fetch")); const config_js_1 = require("./config.js"); -async function fetchRegistry() { - try { - const response = await (0, node_fetch_1.default)(`${config_js_1.REGISTRY_URL}/registry.json`); - if (!response.ok) { - throw new Error(`Failed to fetch registry: ${response.statusText}`); +function parseComponentSpecifier(input, config) { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error('Component name cannot be empty.'); + } + if (trimmed.startsWith('@')) { + const parts = trimmed.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid component specifier "${input}". Use "button" or "@scope/component".`); } - return await response.json(); + const scope = parts[0]; + const registryUrl = resolveRegistry(scope, config); + return { + raw: trimmed, + scope, + componentName: parts[1], + manifestUrl: buildManifestUrl(registryUrl, parts[1]), + fileBaseUrl: buildFileBaseUrl(registryUrl), + registry: config.registries[scope], + }; } - catch (error) { - throw new Error(`Failed to fetch registry: ${error}`); + return { + raw: trimmed, + scope: null, + componentName: trimmed, + manifestUrl: buildManifestUrl(config.defaultRegistry, trimmed), + fileBaseUrl: buildFileBaseUrl(config.defaultRegistry), + registry: config.defaultRegistry, + }; +} +function resolveRegistry(scope, config) { + const registryUrl = config.registries[scope]; + if (!registryUrl) { + throw new Error(`Unknown registry scope "${scope}". Add it to "registries" in watermelon.json.`); } + return getRegistryTemplate(registryUrl); } -async function fetchComponentFile(componentPath) { +async function fetchComponent(specifier) { + const manifestUrl = specifier.manifestUrl; + let response; + try { + response = await (0, node_fetch_1.default)(manifestUrl); + } + catch (error) { + throw new Error(`Network error while fetching "${specifier.raw}" from ${manifestUrl}: ${getErrorMessage(error)}`); + } + if (response.status === 404) { + throw new Error(`Component "${specifier.raw}" was not found at ${manifestUrl}.`); + } + if (!response.ok) { + throw new Error(`Failed to fetch "${specifier.raw}" from ${manifestUrl}: ${response.status} ${response.statusText}`); + } + let data; try { - // Fetch the actual component file from GitHub - const url = `${config_js_1.REGISTRY_URL}/src/${componentPath}`; - const response = await (0, node_fetch_1.default)(url); - if (!response.ok) { - throw new Error(`Failed to fetch component file: ${response.statusText}`); + data = await response.json(); + } + catch (error) { + throw new Error(`Invalid JSON received for "${specifier.raw}" from ${manifestUrl}: ${getErrorMessage(error)}`); + } + return validateManifest(data, specifier.raw); +} +async function collectComponents(inputs, config) { + const resolved = new Map(); + async function visit(input, parentScope) { + const nextInput = parentScope && !input.startsWith('@') + ? `${parentScope}/${input}` + : input; + const specifier = parseComponentSpecifier(nextInput, config); + const key = specifier.raw; + if (resolved.has(key)) { + return; } - return await response.text(); + const manifest = await fetchComponent(specifier); + resolved.set(key, { specifier, manifest }); + for (const dependency of manifest.registryDependencies ?? []) { + await visit(dependency, specifier.scope); + } + } + for (const input of inputs) { + await visit(input, null); + } + return Array.from(resolved.values()); +} +async function downloadRegistryFile(fileBaseUrl, file) { + const sourceUrl = file.url ?? buildFileUrl(fileBaseUrl, file.path); + let response; + try { + response = await (0, node_fetch_1.default)(sourceUrl); } catch (error) { - throw new Error(`Failed to fetch component file: ${error}`); + throw new Error(`Network error while downloading "${file.path}" from ${sourceUrl}: ${getErrorMessage(error)}`); + } + if (!response.ok) { + throw new Error(`Failed to download "${file.path}" from ${sourceUrl}: ${response.status} ${response.statusText}`); } + return response.text(); } -async function getComponentInfo(name) { - const registry = await fetchRegistry(); - return registry.components[name] || null; +function buildManifestUrl(registrySource, componentName) { + const template = typeof registrySource === 'string' + ? registrySource + : getRegistryTemplate(registrySource); + if (template.includes('{name}')) { + return template.replaceAll('{name}', encodeURIComponent(componentName)); + } + return `${normalizeRegistryUrl(template)}/${encodeURIComponent(componentName)}.json`; +} +function buildFileUrl(fileBaseUrl, filePath) { + const normalizedPath = filePath + .replace(/^\/+/, '') + .split('/') + .map(encodeURIComponent) + .join('/'); + return `${normalizeRegistryUrl(fileBaseUrl)}/files/${normalizedPath}`; } -function getAllComponentDependencies(componentName, registry, visited = new Set()) { - if (visited.has(componentName)) { - return []; +function buildFileBaseUrl(registrySource) { + const template = typeof registrySource === 'string' + ? registrySource + : getRegistryTemplate(registrySource); + const normalized = normalizeRegistryUrl(template); + if (!normalized.includes('{name}')) { + return normalized; } - visited.add(componentName); - const component = registry.components[componentName]; - if (!component) { - return []; + const withoutPlaceholder = normalized.replace(/\/?\{name\}\.json$/, ''); + const withoutRegistrySegment = withoutPlaceholder.replace(/\/r$/, ''); + return withoutRegistrySegment; +} +function normalizeRegistryUrl(registryUrl) { + return registryUrl.replace(/\/+$/, ''); +} +function getRegistryTemplate(registry) { + return (0, config_js_1.isRegistryDirectoryEntry)(registry) ? registry.url : registry; +} +function validateManifest(data, name) { + if (!data || typeof data !== 'object') { + throw new Error(`Registry manifest for "${name}" must be an object.`); + } + const manifest = data; + if (typeof manifest.name !== 'string' || manifest.name.length === 0) { + throw new Error(`Registry manifest for "${name}" is missing a valid "name".`); } - const dependencies = [component]; - if (component.registryDependencies) { - for (const dep of component.registryDependencies) { - dependencies.push(...getAllComponentDependencies(dep, registry, visited)); + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + throw new Error(`Registry manifest for "${name}" must include at least one file.`); + } + for (const file of manifest.files) { + if (!file || typeof file !== 'object' || typeof file.path !== 'string') { + throw new Error(`Registry manifest for "${name}" contains an invalid file entry.`); + } + if (file.url !== undefined && typeof file.url !== 'string') { + throw new Error(`Registry manifest for "${name}" contains an invalid file URL.`); } } - return dependencies; + for (const field of ['dependencies', 'registryDependencies']) { + const value = manifest[field]; + if (value !== undefined && (!Array.isArray(value) || value.some((item) => typeof item !== 'string'))) { + throw new Error(`Registry manifest for "${name}" has an invalid "${field}" field.`); + } + } + return { + name: manifest.name, + files: manifest.files, + dependencies: manifest.dependencies ?? [], + registryDependencies: manifest.registryDependencies ?? [], + }; +} +function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error); } diff --git a/apps/cli/examples/button.json b/apps/cli/examples/button.json new file mode 100644 index 0000000..f92e63f --- /dev/null +++ b/apps/cli/examples/button.json @@ -0,0 +1,16 @@ +{ + "name": "button", + "dependencies": [ + "clsx", + "tailwind-merge" + ], + "registryDependencies": [ + "@watermelon/utils" + ], + "files": [ + { + "path": "components/ui/button.tsx", + "url": "https://registry.watermelon.dev/files/components/ui/button.tsx" + } + ] +} diff --git a/apps/cli/examples/watermelon.json b/apps/cli/examples/watermelon.json new file mode 100644 index 0000000..8a04606 --- /dev/null +++ b/apps/cli/examples/watermelon.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://registry.watermelon.dev/schema/watermelon.json", + "style": "default", + "tailwind": { + "config": "tailwind.config.js", + "css": "global.css", + "baseColor": "slate" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + }, + "registries": { + "@watermelon": { + "name": "@watermelon", + "homepage": "https://registry.watermelon.dev", + "url": "https://registry.watermelon.dev/{name}.json", + "description": "Official Watermelon component registry." + }, + "@aceternity": { + "name": "@aceternity", + "homepage": "https://aceternity.com", + "url": "https://aceternity.com/r/{name}.json", + "description": "Aceternity UI component registry." + } + }, + "defaultRegistry": "https://registry.watermelon.dev/{name}.json" +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index d75eed2..e049bda 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -3,31 +3,29 @@ import { Command } from 'commander'; import chalk from 'chalk'; import prompts from 'prompts'; import ora from 'ora'; -import path from 'path'; -import fs from 'fs-extra'; -import { getConfig, setConfig, DEFAULT_CONFIG, type WatermelonConfig } from './utils/config.js'; -import { fetchRegistry, fetchComponentFile, getAllComponentDependencies, type Registry } from './utils/registry.js'; -import { installDependencies, getMissingDependencies } from './utils/dependencies.js'; -import { writeComponent, transformImports, ensureUtilsFile, ensureComponentsDirectory } from './utils/files.js'; +import { DEFAULT_CONFIG, getConfig, setConfig, type WatermelonConfig } from './utils/config.js'; +import { collectComponents, downloadRegistryFile } from './utils/registry.js'; +import { getMissingDependencies, installDependencies } from './utils/dependencies.js'; +import { ensureComponentsDirectory, ensureUtilsFile, installFiles } from './utils/files.js'; const program = new Command(); program .name('watermelon') .description('CLI for Watermelon Design System') - .version('0.0.1'); + .version('0.0.2'); program .command('init') .description('Initialize Watermelon in your project') .action(async () => { - console.log(chalk.bold.cyan('๐Ÿ‰ Welcome to Watermelon!\n')); + console.log(chalk.bold.cyan('Watermelon setup\n')); const cwd = process.cwd(); const existingConfig = await getConfig(cwd); if (existingConfig) { - console.log(chalk.yellow('โš ๏ธ Watermelon is already initialized in this project.')); + console.log(chalk.yellow('Watermelon is already initialized in this project.')); const { overwrite } = await prompts({ type: 'confirm', name: 'overwrite', @@ -84,16 +82,10 @@ program const spinner = ora('Setting up project...').start(); try { - // Save config await setConfig(cwd, config); - - // Create directories await ensureComponentsDirectory(cwd, config); - - // Create utils file await ensureUtilsFile(cwd, config); - // Install required dependencies const requiredDeps = [ 'clsx', 'tailwind-merge', @@ -107,118 +99,131 @@ program await installDependencies(missingDeps, cwd); } - spinner.succeed(chalk.green('โœ“ Watermelon initialized successfully!')); - - console.log('\n' + chalk.bold('Next steps:')); - console.log(chalk.gray(' 1. Add components: ') + chalk.cyan('watermelon add button')); - console.log(chalk.gray(' 2. Import in your app: ') + chalk.cyan('import { Button } from "@/components/ui/button"')); - console.log(''); + spinner.succeed(chalk.green('Watermelon initialized successfully.')); + console.log(`\n${chalk.bold('Next steps')}`); + console.log(` ${chalk.gray('Add a component:')} ${chalk.cyan('watermelon add button')}`); + console.log(` ${chalk.gray('Use a namespace:')} ${chalk.cyan('watermelon add @aceternity/card')}`); } catch (error) { spinner.fail('Failed to initialize Watermelon'); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); + console.error(chalk.red(getErrorMessage(error))); process.exit(1); } }); program .command('add') - .description('Add components to your project') - .argument('[components...]', 'components to add') - .action(async (components: string[]) => { + .description('Add components from one or more registries') + .argument('', 'components to add') + .option('-f, --force', 'overwrite existing files') + .option('-p, --path ', 'custom install directory') + .option('--dry-run', 'preview changes without writing files or installing dependencies') + .action(async (components: string[], options: AddOptions) => { const cwd = process.cwd(); const config = await getConfig(cwd); if (!config) { - console.log(chalk.red('โœ— Watermelon is not initialized in this project.')); - console.log(chalk.gray(' Run ') + chalk.cyan('watermelon init') + chalk.gray(' first.')); - return; - } - - if (!components || components.length === 0) { - console.log(chalk.red('โœ— Please specify at least one component.')); - console.log(chalk.gray(' Example: ') + chalk.cyan('watermelon add button')); - return; + console.log(chalk.red('Watermelon is not initialized in this project.')); + console.log(`${chalk.gray('Run')} ${chalk.cyan('watermelon init')} ${chalk.gray('first.')}`); + process.exit(1); } - const spinner = ora('Fetching registry...').start(); + const spinner = ora('Resolving component manifests...').start(); try { - const registry = await fetchRegistry(); - spinner.succeed('Registry fetched'); - - // Validate all components exist - for (const componentName of components) { - if (!registry.components[componentName]) { - console.log(chalk.red(`โœ— Component "${componentName}" not found in registry.`)); - console.log(chalk.gray(' Available components: ') + - Object.keys(registry.components).join(', ')); - return; - } - } - - // Get all dependencies - const allComponents = new Set(); - for (const componentName of components) { - const deps = getAllComponentDependencies(componentName, registry); - deps.forEach(dep => allComponents.add(dep.name)); - } + const resolvedComponents = await collectComponents(components, config); + spinner.succeed(`Resolved ${resolvedComponents.length} component${resolvedComponents.length === 1 ? '' : 's'}.`); - console.log(chalk.bold(`\nComponents to install: `) + - Array.from(allComponents).join(', ')); - - // Collect all npm dependencies - const allNpmDeps = new Set(); - for (const componentName of allComponents) { - const component = registry.components[componentName]; - if (component.dependencies) { - component.dependencies.forEach(dep => allNpmDeps.add(dep)); - } - } + const dependencyNames = Array.from( + new Set(resolvedComponents.flatMap((component) => component.manifest.dependencies ?? [])) + ).sort(); - // Check for missing dependencies - const missingDeps = await getMissingDependencies( - Array.from(allNpmDeps), - cwd - ); + const missingDependencies = await getMissingDependencies(dependencyNames, cwd); - if (missingDeps.length > 0) { - console.log(chalk.yellow(`\n๐Ÿ“ฆ Installing ${missingDeps.length} dependencies...\n`)); - await installDependencies(missingDeps, cwd); + if (options.dryRun) { + printDryRunSummary(resolvedComponents, missingDependencies, options.path); + } else if (missingDependencies.length > 0) { + await installDependencies(missingDependencies, cwd); } - // Install components - console.log(chalk.bold('\n๐Ÿ“ Installing components...\n')); + const fileSpinner = ora(options.dryRun ? 'Planning file changes...' : 'Installing component files...').start(); - for (const componentName of allComponents) { - const componentSpinner = ora(`Installing ${componentName}...`).start(); - const component = registry.components[componentName]; - - try { - for (const file of component.files) { - // Fetch the component file content - const content = await fetchComponentFile(file.path); - - // Transform imports to use user's aliases - const transformedContent = transformImports(content, config); + const plannedFiles = await installFiles(resolvedComponents, { + cwd, + config, + installPath: options.path, + force: options.force, + dryRun: options.dryRun, + downloadFile: (component, file) => downloadRegistryFile(component.specifier.fileBaseUrl, file), + }); - // Write to disk - await writeComponent(file.path, transformedContent, cwd); - } + fileSpinner.succeed( + options.dryRun + ? `Planned ${plannedFiles.length} file change${plannedFiles.length === 1 ? '' : 's'}.` + : `Installed ${plannedFiles.length} file${plannedFiles.length === 1 ? '' : 's'}.` + ); - componentSpinner.succeed(chalk.green(`${componentName} installed`)); - } catch (error) { - componentSpinner.fail(chalk.red(`Failed to install ${componentName}`)); - throw error; - } + if (options.dryRun) { + printDryRunFiles(plannedFiles); + return; } - console.log(chalk.bold.green('\nโœ“ All components installed successfully!\n')); + console.log(`\n${chalk.bold.green('Installed components')}`); + for (const component of resolvedComponents) { + console.log(` ${chalk.cyan(component.specifier.raw)}`); + } + if (missingDependencies.length > 0) { + console.log(`\n${chalk.bold('Installed dependencies')}`); + console.log(` ${missingDependencies.join(', ')}`); + } } catch (error) { spinner.fail('Failed to add components'); - console.error(chalk.red(error instanceof Error ? error.message : String(error))); + console.error(chalk.red(getErrorMessage(error))); process.exit(1); } }); program.parse(); + +type AddOptions = { + force?: boolean; + path?: string; + dryRun?: boolean; +}; + +function printDryRunSummary( + components: Awaited>, + missingDependencies: string[], + installPath?: string +) { + console.log(`\n${chalk.bold('Dry run')}`); + console.log(` ${chalk.gray('Components:')} ${components.map((component) => component.specifier.raw).join(', ')}`); + + if (installPath) { + console.log(` ${chalk.gray('Install path:')} ${installPath}`); + } + + if (missingDependencies.length > 0) { + console.log(` ${chalk.gray('Missing dependencies:')} ${missingDependencies.join(', ')}`); + } else { + console.log(` ${chalk.gray('Missing dependencies:')} none`); + } +} + +function printDryRunFiles( + plannedFiles: Array<{ relativePath: string; exists: boolean }> +) { + if (plannedFiles.length === 0) { + return; + } + + console.log(`\n${chalk.bold('Files')}`); + for (const file of plannedFiles) { + const label = file.exists ? chalk.yellow('overwrite') : chalk.green('create'); + console.log(` ${label} ${file.relativePath}`); + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/cli/src/utils/config.ts b/apps/cli/src/utils/config.ts index ecc7dd4..7bd9c78 100644 --- a/apps/cli/src/utils/config.ts +++ b/apps/cli/src/utils/config.ts @@ -13,8 +13,34 @@ export interface WatermelonConfig { components: string; utils: string; }; + registries: Record; + defaultRegistry: string; } +export interface RegistryDirectoryEntry { + name: string; + homepage?: string; + url: string; + description?: string; +} + +export type RegistrySource = string | RegistryDirectoryEntry; + +export const DEFAULT_REGISTRIES = { + '@watermelon': { + name: '@watermelon', + homepage: 'https://registry.watermelon.dev', + url: 'https://registry.watermelon.dev/{name}.json', + description: 'Official Watermelon component registry.', + }, + '@aceternity': { + name: '@aceternity', + homepage: 'https://aceternity.com', + url: 'https://aceternity.com/registry/{name}.json', + description: 'Aceternity component registry.', + }, +} satisfies Record; + export const DEFAULT_CONFIG: WatermelonConfig = { style: 'default', tailwind: { @@ -26,37 +52,65 @@ export const DEFAULT_CONFIG: WatermelonConfig = { components: '@/components', utils: '@/lib/utils', }, + registries: DEFAULT_REGISTRIES, + defaultRegistry: 'https://registry.watermelon.dev/{name}.json', }; -export const REGISTRY_URL = 'https://raw.githubusercontent.com/vanshpatelx/RN/main/packages/registry'; +const CONFIG_FILE = 'watermelon.json'; export async function getConfig(cwd: string): Promise { try { - const configPath = path.join(cwd, 'watermelon.json'); + const configPath = path.join(cwd, CONFIG_FILE); const configExists = await fs.pathExists(configPath); if (!configExists) { return null; } - const config = await fs.readJson(configPath); - return config as WatermelonConfig; - } catch (error) { + const config = await fs.readJson(configPath) as Partial; + return mergeConfig(config); + } catch { return null; } } export async function setConfig(cwd: string, config: WatermelonConfig): Promise { - const configPath = path.join(cwd, 'watermelon.json'); + const configPath = path.join(cwd, CONFIG_FILE); await fs.writeJson(configPath, config, { spaces: 2 }); } +export function mergeConfig(config: Partial): WatermelonConfig { + return { + ...DEFAULT_CONFIG, + ...config, + tailwind: { + ...DEFAULT_CONFIG.tailwind, + ...config.tailwind, + }, + aliases: { + ...DEFAULT_CONFIG.aliases, + ...config.aliases, + }, + registries: { + ...DEFAULT_CONFIG.registries, + ...config.registries, + }, + defaultRegistry: config.defaultRegistry ?? DEFAULT_CONFIG.defaultRegistry, + }; +} + +export function isRegistryDirectoryEntry(value: RegistrySource): value is RegistryDirectoryEntry { + return typeof value === 'object' && value !== null && 'url' in value && 'name' in value; +} + export function resolveImport(alias: string, config: WatermelonConfig): string { if (alias === '@/components') { return config.aliases.components; } + if (alias === '@/lib/utils') { return config.aliases.utils; } + return alias; } diff --git a/apps/cli/src/utils/files.ts b/apps/cli/src/utils/files.ts index 05637d6..cf05454 100644 --- a/apps/cli/src/utils/files.ts +++ b/apps/cli/src/utils/files.ts @@ -1,30 +1,67 @@ import fs from 'fs-extra'; import path from 'path'; import type { WatermelonConfig } from './config.js'; +import type { RegistryFile, ResolvedComponent } from './registry.js'; -export async function writeComponent( - targetPath: string, - content: string, - cwd: string -): Promise { - const fullPath = path.join(cwd, targetPath); - await fs.ensureDir(path.dirname(fullPath)); - await fs.writeFile(fullPath, content, 'utf-8'); +export interface InstallFilesOptions { + cwd: string; + config: WatermelonConfig; + installPath?: string; + force?: boolean; + dryRun?: boolean; + downloadFile: (component: ResolvedComponent, file: RegistryFile) => Promise; +} + +export interface PlannedFile { + targetPath: string; + relativePath: string; + exists: boolean; +} + +export async function installFiles( + components: ResolvedComponent[], + options: InstallFilesOptions +): Promise { + const plannedFiles: PlannedFile[] = []; + const targetRoot = resolveTargetRoot(options.cwd, options.installPath); + + for (const component of components) { + for (const file of component.manifest.files) { + const targetPath = path.join(targetRoot, file.path); + const exists = await fs.pathExists(targetPath); + + if (exists && !options.force) { + throw new Error( + `File already exists: ${path.relative(options.cwd, targetPath)}. Re-run with --force to overwrite.` + ); + } + + plannedFiles.push({ + targetPath, + relativePath: path.relative(options.cwd, targetPath), + exists, + }); + + if (options.dryRun) { + continue; + } + + const content = await options.downloadFile(component, file); + const transformedContent = transformImports(content, options.config); + + await fs.ensureDir(path.dirname(targetPath)); + await fs.writeFile(targetPath, transformedContent, 'utf-8'); + } + } + + return plannedFiles; } export function transformImports(content: string, config: WatermelonConfig): string { let transformed = content; - // Transform @/registry/* imports to @/components/* - transformed = transformed.replace( - /@\/registry\/components/g, - config.aliases.components - ); - - transformed = transformed.replace( - /@\/registry\/lib/g, - path.dirname(config.aliases.utils) - ); + transformed = transformed.replace(/@\/registry\/components/g, config.aliases.components); + transformed = transformed.replace(/@\/registry\/lib/g, path.dirname(config.aliases.utils)); return transformed; } @@ -41,8 +78,7 @@ export function cn(...inputs: ClassValue[]) { const utilsPath = config.aliases.utils.replace('@/', ''); const fullPath = path.join(cwd, utilsPath + '.ts'); - const exists = await fs.pathExists(fullPath); - if (!exists) { + if (!(await fs.pathExists(fullPath))) { await fs.ensureDir(path.dirname(fullPath)); await fs.writeFile(fullPath, utilsContent, 'utf-8'); } @@ -50,6 +86,15 @@ export function cn(...inputs: ClassValue[]) { export async function ensureComponentsDirectory(cwd: string, config: WatermelonConfig): Promise { const componentsPath = config.aliases.components.replace('@/', ''); - const uiPath = path.join(cwd, componentsPath, 'ui'); - await fs.ensureDir(uiPath); + await fs.ensureDir(path.join(cwd, componentsPath, 'ui')); +} + +function resolveTargetRoot(cwd: string, installPath?: string): string { + if (!installPath) { + return cwd; + } + + return path.isAbsolute(installPath) + ? installPath + : path.join(cwd, installPath); } diff --git a/apps/cli/src/utils/registry.ts b/apps/cli/src/utils/registry.ts index 732257c..ff29498 100644 --- a/apps/cli/src/utils/registry.ts +++ b/apps/cli/src/utils/registry.ts @@ -1,83 +1,271 @@ import fetch from 'node-fetch'; -import { REGISTRY_URL } from './config.js'; +import { + isRegistryDirectoryEntry, + type RegistryDirectoryEntry, + type RegistrySource, + type WatermelonConfig, +} from './config.js'; -export interface RegistryItem { +export interface ComponentSpecifier { + raw: string; + scope: string | null; + componentName: string; + manifestUrl: string; + fileBaseUrl: string; + registry: RegistrySource; +} + +export interface RegistryFile { + path: string; + url?: string; +} + +export interface ComponentManifest { name: string; - type: string; - files: Array<{ - path: string; - content: string; - type: string; - }>; dependencies?: string[]; registryDependencies?: string[]; - tailwind?: { - config: Record; + files: RegistryFile[]; +} + +export interface ResolvedComponent { + specifier: ComponentSpecifier; + manifest: ComponentManifest; +} + +export function parseComponentSpecifier( + input: string, + config: WatermelonConfig +): ComponentSpecifier { + const trimmed = input.trim(); + + if (!trimmed) { + throw new Error('Component name cannot be empty.'); + } + + if (trimmed.startsWith('@')) { + const parts = trimmed.split('/'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid component specifier "${input}". Use "button" or "@scope/component".` + ); + } + + const scope = parts[0]; + const registryUrl = resolveRegistry(scope, config); + + return { + raw: trimmed, + scope, + componentName: parts[1], + manifestUrl: buildManifestUrl(registryUrl, parts[1]), + fileBaseUrl: buildFileBaseUrl(registryUrl), + registry: config.registries[scope], + }; + } + + return { + raw: trimmed, + scope: null, + componentName: trimmed, + manifestUrl: buildManifestUrl(config.defaultRegistry, trimmed), + fileBaseUrl: buildFileBaseUrl(config.defaultRegistry), + registry: config.defaultRegistry, }; } -export interface Registry { - components: Record; +export function resolveRegistry(scope: string, config: WatermelonConfig): string { + const registryUrl = config.registries[scope]; + + if (!registryUrl) { + throw new Error( + `Unknown registry scope "${scope}". Add it to "registries" in watermelon.json.` + ); + } + + return getRegistryTemplate(registryUrl); } -export async function fetchRegistry(): Promise { +export async function fetchComponent( + specifier: ComponentSpecifier +): Promise { + const manifestUrl = specifier.manifestUrl; + + let response; try { - const response = await fetch(`${REGISTRY_URL}/registry.json`); - if (!response.ok) { - throw new Error(`Failed to fetch registry: ${response.statusText}`); - } - return await response.json() as Registry; + response = await fetch(manifestUrl); } catch (error) { - throw new Error(`Failed to fetch registry: ${error}`); + throw new Error( + `Network error while fetching "${specifier.raw}" from ${manifestUrl}: ${getErrorMessage(error)}` + ); } -} -export async function fetchComponentFile(componentPath: string): Promise { + if (response.status === 404) { + throw new Error( + `Component "${specifier.raw}" was not found at ${manifestUrl}.` + ); + } + + if (!response.ok) { + throw new Error( + `Failed to fetch "${specifier.raw}" from ${manifestUrl}: ${response.status} ${response.statusText}` + ); + } + + let data: unknown; try { - // Fetch the actual component file from GitHub - const url = `${REGISTRY_URL}/src/${componentPath}`; - const response = await fetch(url); + data = await response.json(); + } catch (error) { + throw new Error( + `Invalid JSON received for "${specifier.raw}" from ${manifestUrl}: ${getErrorMessage(error)}` + ); + } + + return validateManifest(data, specifier.raw); +} + +export async function collectComponents( + inputs: string[], + config: WatermelonConfig +): Promise { + const resolved = new Map(); + + async function visit(input: string, parentScope: string | null): Promise { + const nextInput = parentScope && !input.startsWith('@') + ? `${parentScope}/${input}` + : input; + const specifier = parseComponentSpecifier(nextInput, config); + const key = specifier.raw; - if (!response.ok) { - throw new Error(`Failed to fetch component file: ${response.statusText}`); + if (resolved.has(key)) { + return; } - return await response.text(); + const manifest = await fetchComponent(specifier); + resolved.set(key, { specifier, manifest }); + + for (const dependency of manifest.registryDependencies ?? []) { + await visit(dependency, specifier.scope); + } + } + + for (const input of inputs) { + await visit(input, null); + } + + return Array.from(resolved.values()); +} + +export async function downloadRegistryFile( + fileBaseUrl: string, + file: RegistryFile +): Promise { + const sourceUrl = file.url ?? buildFileUrl(fileBaseUrl, file.path); + + let response; + try { + response = await fetch(sourceUrl); } catch (error) { - throw new Error(`Failed to fetch component file: ${error}`); + throw new Error( + `Network error while downloading "${file.path}" from ${sourceUrl}: ${getErrorMessage(error)}` + ); + } + + if (!response.ok) { + throw new Error( + `Failed to download "${file.path}" from ${sourceUrl}: ${response.status} ${response.statusText}` + ); + } + + return response.text(); +} + +function buildManifestUrl(registrySource: RegistrySource | string, componentName: string): string { + const template = typeof registrySource === 'string' + ? registrySource + : getRegistryTemplate(registrySource); + + if (template.includes('{name}')) { + return template.replaceAll('{name}', encodeURIComponent(componentName)); + } + + return `${normalizeRegistryUrl(template)}/${encodeURIComponent(componentName)}.json`; +} + +function buildFileUrl(fileBaseUrl: string, filePath: string): string { + const normalizedPath = filePath + .replace(/^\/+/, '') + .split('/') + .map(encodeURIComponent) + .join('/'); + + return `${normalizeRegistryUrl(fileBaseUrl)}/files/${normalizedPath}`; +} + +function buildFileBaseUrl(registrySource: RegistrySource | string): string { + const template = typeof registrySource === 'string' + ? registrySource + : getRegistryTemplate(registrySource); + + const normalized = normalizeRegistryUrl(template); + + if (!normalized.includes('{name}')) { + return normalized; } + + const withoutPlaceholder = normalized.replace(/\/?\{name\}\.json$/, ''); + const withoutRegistrySegment = withoutPlaceholder.replace(/\/r$/, ''); + + return withoutRegistrySegment; +} + +function normalizeRegistryUrl(registryUrl: string): string { + return registryUrl.replace(/\/+$/, ''); } -export async function getComponentInfo(name: string): Promise { - const registry = await fetchRegistry(); - return registry.components[name] || null; +function getRegistryTemplate(registry: RegistrySource | RegistryDirectoryEntry): string { + return isRegistryDirectoryEntry(registry) ? registry.url : registry; } -export function getAllComponentDependencies( - componentName: string, - registry: Registry, - visited = new Set() -): RegistryItem[] { - if (visited.has(componentName)) { - return []; +function validateManifest(data: unknown, name: string): ComponentManifest { + if (!data || typeof data !== 'object') { + throw new Error(`Registry manifest for "${name}" must be an object.`); } - visited.add(componentName); - const component = registry.components[componentName]; + const manifest = data as Partial; + + if (typeof manifest.name !== 'string' || manifest.name.length === 0) { + throw new Error(`Registry manifest for "${name}" is missing a valid "name".`); + } - if (!component) { - return []; + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + throw new Error(`Registry manifest for "${name}" must include at least one file.`); } - const dependencies: RegistryItem[] = [component]; + for (const file of manifest.files) { + if (!file || typeof file !== 'object' || typeof file.path !== 'string') { + throw new Error(`Registry manifest for "${name}" contains an invalid file entry.`); + } - if (component.registryDependencies) { - for (const dep of component.registryDependencies) { - dependencies.push( - ...getAllComponentDependencies(dep, registry, visited) - ); + if (file.url !== undefined && typeof file.url !== 'string') { + throw new Error(`Registry manifest for "${name}" contains an invalid file URL.`); } } - return dependencies; + for (const field of ['dependencies', 'registryDependencies'] as const) { + const value = manifest[field]; + if (value !== undefined && (!Array.isArray(value) || value.some((item) => typeof item !== 'string'))) { + throw new Error(`Registry manifest for "${name}" has an invalid "${field}" field.`); + } + } + + return { + name: manifest.name, + files: manifest.files, + dependencies: manifest.dependencies ?? [], + registryDependencies: manifest.registryDependencies ?? [], + }; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } diff --git a/apps/platform/app/(docs)/cli/page.tsx b/apps/platform/app/(docs)/cli/page.tsx index aad537e..6c1c5ec 100644 --- a/apps/platform/app/(docs)/cli/page.tsx +++ b/apps/platform/app/(docs)/cli/page.tsx @@ -1,6 +1,12 @@ import { PageHeader } from "@/components/core/typography"; import { CodeBlock } from "@/components/showcase/code-block"; import MotionDiv from "@/components/core/motion-div"; +import { + DocSection, + OnThisPage, +} from "@/components/showcase/docs-primitives"; + +const toc = [{ id: "commands", title: "Commands" }]; export default function CliPage() { return ( @@ -11,17 +17,21 @@ export default function CliPage() { transition={{ duration: 0.3 }} className="mr-auto max-w-5xl space-y-8" > + + - - {`watermelon init + + + {`watermelon init watermelon add button watermelon add button text`} - + + ); } diff --git a/apps/platform/app/(docs)/components/[slug]/page.tsx b/apps/platform/app/(docs)/components/[...slug]/page.tsx similarity index 63% rename from apps/platform/app/(docs)/components/[slug]/page.tsx rename to apps/platform/app/(docs)/components/[...slug]/page.tsx index 51a1e6e..766baac 100644 --- a/apps/platform/app/(docs)/components/[slug]/page.tsx +++ b/apps/platform/app/(docs)/components/[...slug]/page.tsx @@ -7,6 +7,8 @@ import { OnThisPage, PreviewCard, } from "@/components/showcase/docs-primitives"; +import { ComponentPreview } from "@/components/mdx/component-preview"; +import { ComponentInstallation } from "@/components/showcase/component-installation"; import { getComponentDoc, getComponentDocPager, @@ -17,21 +19,23 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; -import { ComponentVideoPreview } from "@/components/showcase/component-video-preview"; import MotionDiv from "@/components/core/motion-div"; export function generateStaticParams() { - return getComponentDocSlugs().map((slug) => ({ slug })); + return getComponentDocSlugs().map((slug) => ({ + slug: [slug], + })); } export async function generateMetadata({ params, }: { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; }): Promise { const { slug } = await params; + const componentSlug = slug[0]; try { - const { meta } = await getComponentDoc(slug); + const { meta } = await getComponentDoc(componentSlug); return { title: `${meta.title} | Watermelon RN`, description: meta.description, @@ -44,17 +48,18 @@ export async function generateMetadata({ export default async function ComponentPage({ params, }: { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; }) { const { slug } = await params; - const doc = await getComponentDoc(slug).catch(() => null); + const componentSlug = slug[0]; + const doc = await getComponentDoc(componentSlug).catch(() => null); if (!doc) { notFound(); } - const { Content, meta } = doc; - const pager = getComponentDocPager(slug); + const { Content, meta, component } = doc; + const pager = getComponentDocPager(componentSlug); return ( -
-
+ +
+

{meta.category}

-

{meta.title}

-

+

+ {meta.title} +

+

{meta.description}

@@ -87,8 +95,8 @@ export default async function ComponentPage({
-
-
+
+
-
-
+ + +
- - ); } diff --git a/apps/platform/app/(docs)/components/page.tsx b/apps/platform/app/(docs)/components/page.tsx index 7fd2633..5b5cfdd 100644 --- a/apps/platform/app/(docs)/components/page.tsx +++ b/apps/platform/app/(docs)/components/page.tsx @@ -1,5 +1,17 @@ import { ComponentsIndexView } from "@/components/showcase/component-docs"; +import { OnThisPage } from "@/components/showcase/docs-primitives"; + +const toc = [ + { id: "component-overview", title: "Overview" }, + { id: "buttons", title: "Buttons" }, + { id: "typography", title: "Typography" }, +]; export default function ComponentsPage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/platform/app/(docs)/installation/page.tsx b/apps/platform/app/(docs)/installation/page.tsx index 5d256e2..1e85b87 100644 --- a/apps/platform/app/(docs)/installation/page.tsx +++ b/apps/platform/app/(docs)/installation/page.tsx @@ -1,6 +1,15 @@ import { PageHeader } from "@/components/core/typography"; import { CodeBlock } from "@/components/showcase/code-block"; import MotionDiv from "@/components/core/motion-div"; +import { + DocSection, + OnThisPage, +} from "@/components/showcase/docs-primitives"; + +const toc = [ + { id: "initialize", title: "Initialize" }, + { id: "config-file", title: "Config file" }, +]; export default function InstallationPage() { return ( @@ -11,19 +20,24 @@ export default function InstallationPage() { transition={{ duration: 0.3 }} className="mr-auto max-w-5xl space-y-8" > + + - - {`watermelon init + + + {`watermelon init watermelon add button text`} - + + - - {`{ + + + {`{ "style": "default", "tailwind": { "config": "tailwind.config.js", @@ -35,7 +49,8 @@ watermelon add button text`} "utils": "@/lib/utils" } }`} - + + ); } diff --git a/apps/platform/app/(docs)/introduction/page.tsx b/apps/platform/app/(docs)/introduction/page.tsx new file mode 100644 index 0000000..bc1e30e --- /dev/null +++ b/apps/platform/app/(docs)/introduction/page.tsx @@ -0,0 +1,141 @@ +import MotionDiv from "@/components/core/motion-div"; +import { PageHeader } from "@/components/core/typography"; +import { CodeBlock } from "@/components/showcase/code-block"; +import { + DocSection, + DocSubsection, + OnThisPage, +} from "@/components/showcase/docs-primitives"; + +const toc = [ + { id: "what-is-watermelon", title: "What is Watermelon?" }, + { id: "why-it-exists", title: "Why it exists" }, + { id: "workflow", title: "Typical workflow" }, + { id: "pick-components", title: "Pick components", depth: 3 }, + { id: "install-with-cli", title: "Install with CLI", depth: 3 }, + { id: "customize-output", title: "Customize output", depth: 3 }, + { id: "project-shape", title: "Project shape" }, + { id: "next-steps", title: "Next steps" }, +]; + +export default function IntroductionPage() { + return ( + + + + + + +

+ Watermelon is a React Native component registry and CLI. Instead of + shipping one giant UI dependency, it lets you pull installable + primitives like button and text directly + into your project. +

+

+ The result is a docs-and-registry workflow that feels familiar if you + have used shadcn on the web, but tuned for native app structure, + NativeWind styling, and Expo-friendly local development. +

+
+ + +
+
+

Own the code

+

+ Installed components live in your codebase, so refactors, + animations, and design tweaks stay under your control. +

+
+
+

Install incrementally

+

+ Start with a couple of primitives, then layer in more only when + the product actually needs them. +

+
+
+

Keep docs close

+

+ Every component has install commands, examples, previews, and API + notes in the same documentation flow. +

+
+
+

Stay native-first

+

+ The primitives are designed for React Native and Expo rather than + being thin ports of web-only assumptions. +

+
+
+
+ + + +

+ Browse the registry, open the docs for a primitive, and verify that + its API and visual behavior match the surface you are building. +

+
+ + +

+ Initialize your project once, then add primitives whenever you need + them. +

+ + {`watermelon init +watermelon add button text`} + +
+ + +

+ After installation, the files are yours. Adjust variants, spacing, + tokens, or composition patterns to fit your product instead of + waiting on a package release. +

+
+
+ + +

+ A typical setup keeps UI primitives in a local component directory, + uses a shared utility helper, and points Tailwind or NativeWind to the + right sources. +

+ + {`app/ +components/ + ui/ + button.tsx + text.tsx +lib/ + utils.ts +global.css +watermelon.json`} + +
+ + +

+ Start with the installation guide if you are setting up a fresh app, + or jump into the components catalog if you already want to pull in a + primitive and begin customizing it. +

+
+
+ ); +} diff --git a/apps/platform/app/(docs)/layout.tsx b/apps/platform/app/(docs)/layout.tsx index 713247f..9ff5a94 100644 --- a/apps/platform/app/(docs)/layout.tsx +++ b/apps/platform/app/(docs)/layout.tsx @@ -1,6 +1,9 @@ -import type { CSSProperties, ReactNode } from "react"; -import { DocsSidebar } from "@/components/core/docs-sidebar"; -import { SidebarProvider } from "@/components/ui/sidebar"; +import type { ReactNode } from "react"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/app-sidebar"; +import { Navbar } from "@/components/core/navbar"; +import { TOCProvider } from "@/components/core/toc-context"; +import { DocsTOC } from "@/components/core/docs-toc"; export default function ComponentsLayout({ children, @@ -8,23 +11,24 @@ export default function ComponentsLayout({ children: ReactNode; }) { return ( - -
-
- -
- -
-
{children}
-
-
-
+ + + + +
+ +
+
+ +
+
{children}
+
+
+
+ +
+
); } diff --git a/apps/platform/app/(docs)/registry/page.tsx b/apps/platform/app/(docs)/registry/page.tsx new file mode 100644 index 0000000..9b77ebd --- /dev/null +++ b/apps/platform/app/(docs)/registry/page.tsx @@ -0,0 +1,163 @@ +import MotionDiv from "@/components/core/motion-div"; +import { PageHeader } from "@/components/core/typography"; +import { CodeBlock } from "@/components/showcase/code-block"; +import { + DocSection, + OnThisPage, +} from "@/components/showcase/docs-primitives"; + +const toc = [ + { id: "overview", title: "Overview" }, + { id: "directory-entry", title: "Directory entry" }, + { id: "component-manifest", title: "Component manifest" }, + { id: "hosting-behavior", title: "Hosting behavior" }, + { id: "install-from-registry", title: "Install from registry" }, + { id: "pr-checklist", title: "PR checklist" }, +]; + +export default function RegistryPage() { + return ( + + + + + + +
+

+ The CLI failed earlier because the default registry URL was pointing + to a domain that did not exist yet. The fix is not just โ€œhost some + JSON somewhereโ€. A registry needs a stable manifest URL pattern, + file hosting that matches the manifest, and a scope entry we can + review and approve. +

+

+ If you want your website to work with Watermelon, publish a + registry on your own domain, make sure the component manifests + resolve correctly, and then submit a PR that adds your directory + entry to our approved registry list. +

+
+
+ + + + {`{ + "name": "@your-scope", + "homepage": "https://your-site.com", + "url": "https://your-site.com/r/{name}.json", + "description": "Short summary of your component registry." +}`} + + +
+

+ The name is the namespace users type in the CLI, for + example watermelon add @your-scope/card. The{" "} + url field is a template. Watermelon replaces{" "} + {"{name}"} with the requested component name. +

+

+ If your registry does not use a template and instead exposes a flat + base URL, you can still host manifests at /button.json + , /card.json, and so on. The shadcn-style template + above is the preferred format because it is more explicit and + easier to review. +

+
+
+ + + + {`{ + "name": "button", + "dependencies": ["clsx", "tailwind-merge"], + "registryDependencies": ["text"], + "files": [ + { + "path": "components/ui/button.tsx", + "url": "https://your-site.com/files/components/ui/button.tsx" + } + ] +}`} + + +
+

+ Each manifest must include a valid name and at least + one files entry. dependencies are npm + packages the CLI installs automatically.{" "} + registryDependencies are other registry components + that should be installed first. +

+

+ Each file needs a target path. You can also provide an + explicit url. If you omit the file URL, Watermelon + falls back to the conventional /files/<path>{" "} + pattern. +

+
+
+ + + + {`GET https://your-site.com/r/button.json +GET https://your-site.com/r/card.json +GET https://your-site.com/files/components/ui/button.tsx +GET https://your-site.com/files/components/ui/card.tsx`} + + +
+

+ Your hosted responses should be public, stable, and return valid + JSON or file contents without requiring a browser session. Avoid + URLs that depend on temporary tokens, client-side rendering, or + HTML wrappers. +

+
+
+ + + + {`watermelon add @your-scope/button +watermelon add @your-scope/card --dry-run +watermelon add @your-scope/button @your-scope/text`} + + + + + + {`- Host your manifests on a public domain you control. +- Serve a directory entry with a stable \`url\` template. +- Make every component manifest return valid JSON. +- Make every file URL downloadable directly. +- Include clear descriptions and ownership details. +- Open a PR adding your registry entry to the approved directory. +- We test the URLs, review the output, and approve if everything resolves correctly.`} + + +
+ ); +} diff --git a/apps/platform/app/dashboard/page.tsx b/apps/platform/app/dashboard/page.tsx new file mode 100644 index 0000000..a1f879f --- /dev/null +++ b/apps/platform/app/dashboard/page.tsx @@ -0,0 +1,55 @@ +import { AppSidebar } from "@/components/app-sidebar" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import { Separator } from "@/components/ui/separator" +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar" + +export default function Page() { + return ( + + + +
+
+ + + + + + + Build Your Application + + + + + Data Fetching + + + +
+
+
+
+
+
+
+
+
+
+ + + ) +} diff --git a/apps/platform/app/globals.css b/apps/platform/app/globals.css index d45eb9a..30cdafe 100644 --- a/apps/platform/app/globals.css +++ b/apps/platform/app/globals.css @@ -7,6 +7,7 @@ --color-foreground: var(--foreground); --font-sans: var(--font-sans); --font-mono: var(--font-geist-mono); + --shadow-3d: inset 0 5px 6px var(--color-border); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -124,4 +125,104 @@ html { @apply font-sans; } + +@keyframes show-top-mask { + to { + --top-mask-height: var(--mask-height); + } + } + +@keyframes hide-bottom-mask { + to { + --bottom-mask-height: 0px; + } + } + +@keyframes show-left-mask { + to { + --left-mask-width: var(--mask-width); + } + } + +@keyframes hide-right-mask { + to { + --right-mask-width: 0px; + } + } +} + +@property --top-mask-height { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --bottom-mask-height { + syntax: ""; + inherits: true; + initial-value: 64px; +} + +@property --left-mask-width { + syntax: ""; + inherits: true; + initial-value: 0px; } + +@property --right-mask-width { + syntax: ""; + inherits: true; + initial-value: 64px; +} + +@utility scroll-fade-effect-y { + --mask-height: 64px; + --mask-offset-top: 0px; + --mask-offset-bottom: 0px; + --scroll-buffer: 2rem; + mask-image: linear-gradient(to top, transparent, black 90%), linear-gradient(to bottom, transparent 0%, black 100%), linear-gradient(black, black); + mask-size: 100% var(--top-mask-height), 100% var(--bottom-mask-height), 100% 100%; + mask-repeat: no-repeat, no-repeat, no-repeat; + mask-position: 0 var(--mask-offset-top), 0 calc(100% - var(--mask-offset-bottom)), 0 0; + mask-composite: exclude; + animation-name: show-top-mask, hide-bottom-mask; + animation-timeline: scroll(self), scroll(self); + animation-range: 0 var(--scroll-buffer), calc(100% - var(--scroll-buffer)) 100%; + animation-fill-mode: both; +} + +@utility scroll-fade-effect-x { + --mask-width: 64px; + --mask-offset-left: 0px; + --mask-offset-right: 0px; + --scroll-buffer: 2rem; + mask-image: linear-gradient(to left, transparent, black 90%), linear-gradient(to right, transparent 0%, black 100%), linear-gradient(black, black); + mask-size: var(--left-mask-width) 100%, var(--right-mask-width) 100%, 100% 100%; + mask-repeat: no-repeat, no-repeat, no-repeat; + mask-position: var(--mask-offset-left) 0, calc(100% - var(--mask-offset-right)) 0, 0 0; + mask-composite: exclude; + animation-name: show-left-mask, hide-right-mask; + animation-timeline: scroll(self inline), scroll(self inline); + animation-range: 0 var(--scroll-buffer), calc(100% - var(--scroll-buffer)) 100%; + animation-fill-mode: both; +} + +/* Hide scrollbar globally */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Apply to all scrollable elements */ +* { + scrollbar-width: none; + -ms-overflow-style: none; +} + +*::-webkit-scrollbar { + display: none; +} \ No newline at end of file diff --git a/apps/platform/app/layout.tsx b/apps/platform/app/layout.tsx index 11f8f28..3b2085d 100644 --- a/apps/platform/app/layout.tsx +++ b/apps/platform/app/layout.tsx @@ -3,7 +3,6 @@ import { Geist_Mono, Newsreader, Roboto } from "next/font/google"; import "./globals.css"; import { cn } from "@/lib/utils"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { Navbar } from "@/components/core/navbar"; import { ThemeProvider } from "next-themes"; const roboto = Roboto({ @@ -48,7 +47,6 @@ export default function RootLayout({ enableSystem > -
{children}
diff --git a/apps/platform/app/page.tsx b/apps/platform/app/page.tsx index 94d18ff..d2e284c 100644 --- a/apps/platform/app/page.tsx +++ b/apps/platform/app/page.tsx @@ -1,6 +1,7 @@ import { getRegistryCatalog } from "@/lib/registry-catalog"; import Link from "next/link"; import { Button } from "@/components/ui/button"; +import { LandingNavbar } from "@/components/core/landing-navbar"; export default async function Home() { const categories = await getRegistryCatalog(); @@ -10,33 +11,84 @@ export default async function Home() { ); return ( -
-
-
-

- React Native Registry -

-

- Watermelon RN -

-

- A minimal registry for installable React Native components. -

-
- - -
-
- -
-

{count} components

-

CLI installation

-
-
-
+ <> +
+ + +
+
+
+

+ React Native Registry +

+

+ Watermelon RN +

+

+ A minimal registry for installable React Native components. +

+
+ + +
+
+ +
+

{count} components

+

CLI installation

+
+
+ +
+
+

+ Why Watermelon +

+

+ Copy the component, own the code, ship faster. +

+
+ +
+
+

Registry-first workflow

+

+ Install only what you need and keep the generated files inside + your app. +

+
+ +
+

Native-first primitives

+

+ Build React Native interfaces with components designed for + real mobile product surfaces. +

+
+ +
+

Docs + CLI together

+

+ Browse examples, install commands, and API details in the same + place. +

+
+ +
+

Composable by default

+

+ Start with a tiny base and adapt each primitive to your design + system over time. +

+
+
+
+
+
+ ); } diff --git a/apps/platform/components.json b/apps/platform/components.json index 54acf35..bb90b58 100644 --- a/apps/platform/components.json +++ b/apps/platform/components.json @@ -23,6 +23,7 @@ }, "registries": { "@magicui": "https://magicui.design/r/{name}", - "@animate-ui": "https://animate-ui.com/r/{name}.json" + "@animate-ui": "https://animate-ui.com/r/{name}.json", + "@ncdai": "https://chanhdai.com/r/{name}.json" } } diff --git a/apps/platform/components/animate-ui/components/buttons/copy.tsx b/apps/platform/components/animate-ui/components/buttons/copy.tsx index b9728c8..e5a8c34 100644 --- a/apps/platform/components/animate-ui/components/buttons/copy.tsx +++ b/apps/platform/components/animate-ui/components/buttons/copy.tsx @@ -1,16 +1,16 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { AnimatePresence, motion } from 'motion/react'; -import { CheckIcon, CopyIcon } from 'lucide-react'; +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { AnimatePresence, motion } from "motion/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { Button as ButtonPrimitive, type ButtonProps as ButtonPrimitiveProps, -} from '@/components/animate-ui/primitives/buttons/button'; -import { cn } from '@/lib/utils'; -import { useControlledState } from '@/hooks/use-controlled-state'; +} from "@/components/animate-ui/primitives/buttons/button"; +import { cn } from "@/lib/utils"; +import { useControlledState } from "@/hooks/use-controlled-state"; const buttonVariants = cva( "flex items-center justify-center rounded-md transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -18,33 +18,33 @@ const buttonVariants = cva( variants: { variant: { default: - 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', - accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90', + "bg-primary border shadow-[inset_0px_3px_3px_var(--color-neutral-300)] dark:shadow-[inset_0px_3px_8px_var(--color-neutral-950)] border-neutral-300 dark:border-neutral-700 text-primary-foreground hover:bg-primary/90", + accent: "bg-accent text-accent-foreground hover:bg-accent/90", destructive: - 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: - 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + "bg-background hover:bg-accent border shadow-[inset_0px_3px_3px_var(--color-neutral-300)] dark:shadow-[inset_0px_3px_8px_var(--color-neutral-950)] border-neutral-300 dark:border-neutral-700 hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: - 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + "bg-secondary border shadow-[inset_0px_3px_3px_var(--color-neutral-300)] dark:shadow-[inset_0px_3px_8px_var(--color-neutral-950)] border-neutral-300 dark:border-neutral-700 text-secondary-foreground hover:bg-secondary/80", ghost: - 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', - link: 'text-primary underline-offset-4 hover:underline', + "border-transparent bg-transparent shadow-none hover:bg-accent hover:text-accent-foreground active:translate-y-0 active:shadow-none dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", }, size: { - default: 'size-9', + default: "size-9", xs: "size-7 [&_svg:not([class*='size-'])]:size-3.5 rounded-md", - sm: 'size-8 rounded-md', - lg: 'size-10 rounded-md', + sm: "size-8 rounded-md", + lg: "size-10 rounded-md", }, }, defaultVariants: { - variant: 'default', - size: 'default', + variant: "default", + size: "default", }, }, ); -type CopyButtonProps = Omit & +type CopyButtonProps = Omit & VariantProps & { content: string; copied?: boolean; @@ -85,7 +85,7 @@ function CopyButton({ }, delay); }) .catch((error) => { - console.error('Error copying command', error); + console.error("Error copying command", error); }); } }, @@ -104,11 +104,11 @@ function CopyButton({ > diff --git a/apps/platform/components/app-sidebar.tsx b/apps/platform/components/app-sidebar.tsx new file mode 100644 index 0000000..90c81db --- /dev/null +++ b/apps/platform/components/app-sidebar.tsx @@ -0,0 +1,110 @@ +"use client"; + +import * as React from "react"; + +import { NavMain } from "@/components/nav-main"; +import { NavSecondary } from "@/components/nav-secondary"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ComputerTerminalIcon, + BookOpen02Icon, + ChartRingIcon, + SentIcon, +} from "@hugeicons/core-free-icons"; +import { getComponentGroups } from "@/lib/component-index"; +import { Logo } from "./core/logo"; +import { Socials } from "./core/socials"; + +const data = { + navMain: [ + { + title: "Getting Started", + url: "#", + icon: , + isActive: true, + items: [ + { + title: "Introduction", + url: "/introduction", + }, + { + title: "Installation", + url: "/installation", + }, + { + title: "CLI", + url: "/cli", + }, + { + title: "Registry", + url: "/registry", + }, + ], + }, + ], + navSecondary: [ + { + title: "Support", + url: "#", + icon: , + }, + { + title: "Feedback", + url: "#", + icon: , + }, + ], +}; + +export function AppSidebar({ ...props }: React.ComponentProps) { + const componentGroups = getComponentGroups(); + + const formattedComponentGroups = componentGroups.map((group) => ({ + title: group.title, + url: "#", + icon: , + items: group.items.map((item) => ({ + title: item.title, + url: `/components/${item.slug}`, + })), + })); + + return ( + + + + +
+ + + + +
+
+
+
+ + + + + + + + +
+ ); +} diff --git a/apps/platform/components/core/3d-container.tsx b/apps/platform/components/core/3d-container.tsx new file mode 100644 index 0000000..a623df9 --- /dev/null +++ b/apps/platform/components/core/3d-container.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +type GlassContainerProps = React.HTMLAttributes & { + variant?: "default" | "soft" | "strong"; +}; + +export function GlassContainer({ + className, + variant = "strong", + children, + ...props +}: GlassContainerProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/platform/components/core/docs-sidebar.tsx b/apps/platform/components/core/docs-sidebar.tsx index d163731..e35f9a9 100644 --- a/apps/platform/components/core/docs-sidebar.tsx +++ b/apps/platform/components/core/docs-sidebar.tsx @@ -22,28 +22,22 @@ export function DocsSidebar({ const componentGroups = getComponentGroups(); return ( - - - - + + +
+ + + Getting Started - + {DOC_SECTIONS.map(({ name, href }) => ( {name} @@ -54,18 +48,18 @@ export function DocsSidebar({ {componentGroups.map((group) => ( - - + + {group.title} - + {group.items.map((component) => ( {component.title} diff --git a/apps/platform/components/core/docs-toc.tsx b/apps/platform/components/core/docs-toc.tsx new file mode 100644 index 0000000..afac36a --- /dev/null +++ b/apps/platform/components/core/docs-toc.tsx @@ -0,0 +1,339 @@ +"use client"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowUp01Icon, Menu01Icon } from "@hugeicons/core-free-icons"; +import { cn } from "@/lib/utils"; +import { useTOC } from "./toc-context"; +import type { TocItem } from "./toc-context"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +type ItemPosition = { + top: number; + depth: number; +}; + +type ActivePosition = { + x: number; + y: number; +}; + +function getX(depth?: number) { + const level = depth === 3 ? 1 : 0; + return level * 16 + 4; +} + +function getScrollContainer(element?: HTMLElement | null) { + return ( + element?.closest('[data-slot="sidebar-inset"]') ?? + document.querySelector('[data-slot="sidebar-inset"]') + ); +} + +function DocsTOCNav({ + items, + activeId, + itemPositions, + activePos, + containerRef, + compact = false, + onItemSelect, +}: { + items: TocItem[]; + activeId: string | null; + itemPositions: Record; + activePos: ActivePosition | null; + containerRef: React.RefObject; + compact?: boolean; + onItemSelect?: () => void; +}) { + const svgPath = useMemo(() => { + if (items.length < 2) return ""; + + let path = ""; + + items.forEach((item, index) => { + const pos = itemPositions[item.id]; + if (!pos) return; + + const x = getX(item.depth); + const y = pos.top + 0.5; + + if (index === 0) { + path += `M ${x} ${y}`; + return; + } + + const prev = items[index - 1]; + const prevPos = itemPositions[prev.id]; + if (!prevPos) return; + + const prevX = getX(prev.depth); + const prevY = prevPos.top + 0.5; + + if (x === prevX) { + path += ` L ${x} ${y}`; + return; + } + + const midY = prevY + (y - prevY) / 2; + path += ` L ${prevX} ${midY - 4} L ${x} ${midY + 4} L ${x} ${y}`; + }); + + return path; + }, [items, itemPositions]); + + return ( +
+ + + + + + {activePos ? ( + +
+ + ) : null} + + + +
+ ); +} + +export function DocsTOC({ mobile = false }: { mobile?: boolean }) { + const { items, activeId, setActiveId } = useTOC(); + const observer = useRef(null); + const containerRef = useRef(null); + + const [itemPositions, setItemPositions] = useState< + Record + >({}); + const [open, setOpen] = useState(false); + + useEffect(() => { + const headings = items + .map((item) => document.getElementById(item.id)) + .filter(Boolean) as HTMLElement[]; + const scrollContainer = getScrollContainer(headings[0]); + + if (observer.current) observer.current.disconnect(); + + observer.current = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort( + (left, right) => + left.boundingClientRect.top - right.boundingClientRect.top, + ); + + if (visible.length > 0) { + setActiveId(visible[0].target.id); + } + }, + { + root: scrollContainer, + rootMargin: "-80px 0px -60% 0px", + threshold: [0, 1], + }, + ); + + headings.forEach((heading) => observer.current?.observe(heading)); + + return () => observer.current?.disconnect(); + }, [items, setActiveId]); + + useEffect(() => { + const scrollContainer = getScrollContainer(containerRef.current); + + const update = () => { + if (!containerRef.current) return; + + const positions: Record = {}; + const links = containerRef.current.querySelectorAll("a[data-toc-id]"); + + links.forEach((link) => { + const id = link.getAttribute("data-toc-id"); + if (!id) return; + + const depth = Number.parseInt( + link.getAttribute("data-depth") || "1", + 10, + ); + const rect = link.getBoundingClientRect(); + const containerRect = containerRef.current!.getBoundingClientRect(); + + positions[id] = { + top: rect.top - containerRect.top + rect.height / 2, + depth, + }; + }); + + setItemPositions(positions); + }; + + update(); + scrollContainer?.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update); + + return () => { + scrollContainer?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [items]); + + const activePos = useMemo(() => { + if (!activeId || !itemPositions[activeId]) return null; + const pos = itemPositions[activeId]; + + return { + x: getX(pos.depth), + y: pos.top, + }; + }, [activeId, itemPositions]); + + if (items.length === 0) return null; + + if (mobile) { + const activeItem = items.find((item) => item.id === activeId) ?? items[0]; + + return ( + + +
+ +
+

+ {activeItem?.title} +

+

+ On this page +

+
+
+ +
+ + +
+ setOpen(false)} + /> +
+
+
+ ); + } + + return ( +
+
+ + + On this page + +
+ +
+ ); +} diff --git a/apps/platform/components/core/landing-navbar.tsx b/apps/platform/components/core/landing-navbar.tsx new file mode 100644 index 0000000..ca35da8 --- /dev/null +++ b/apps/platform/components/core/landing-navbar.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Logo } from "./logo"; +import ThemeToggle from "./theme-toggle"; +import { ProgressiveBlur } from "../ui/progressive-blur"; + +const navLinks = [ + { label: "Components", href: "/components" }, + { label: "Installation", href: "/installation" }, + { label: "CLI", href: "/cli" }, + { label: "Registry", href: "/registry" }, +]; + +export function LandingNavbar() { + const pathname = usePathname(); + + return ( +
+ + + +
+ ); +} diff --git a/apps/platform/components/core/logo.tsx b/apps/platform/components/core/logo.tsx index a9d8e71..63fe565 100644 --- a/apps/platform/components/core/logo.tsx +++ b/apps/platform/components/core/logo.tsx @@ -3,11 +3,14 @@ import Link from "next/link"; export const Logo = () => { return ( - +
Logo
- + Watermelon RN diff --git a/apps/platform/components/core/navbar.tsx b/apps/platform/components/core/navbar.tsx index b2e78cc..89fefdd 100644 --- a/apps/platform/components/core/navbar.tsx +++ b/apps/platform/components/core/navbar.tsx @@ -3,101 +3,83 @@ import * as React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Menu01Icon, Cancel01Icon } from "@hugeicons/core-free-icons"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { CommandMenu } from "./command-pallete"; import { Logo } from "./logo"; import ThemeToggle from "./theme-toggle"; +import { ProgressiveBlur } from "../ui/progressive-blur"; +import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; +import { useIsMobile } from "@/hooks/use-mobile"; const navLinks = [ { label: "Components", href: "/components" }, { label: "Installation", href: "/installation" }, { label: "CLI", href: "/cli" }, + { label: "Registry", href: "/registry" }, ]; // โ”€โ”€โ”€ Navbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function Navbar() { const pathname = usePathname(); - const [mobileOpen, setMobileOpen] = React.useState(false); + const { state } = useSidebar(); + const isOpen = state === "expanded"; + const isMobile = useIsMobile(); return ( -
-
- {/* Logo */} - +
+ {/* Progressive blur effect - fades from top (blurry) to bottom (clear) */} + - {/* Desktop nav links */} - + {/* Navbar content */} +
); } diff --git a/apps/platform/components/core/toc-context.tsx b/apps/platform/components/core/toc-context.tsx new file mode 100644 index 0000000..4d45228 --- /dev/null +++ b/apps/platform/components/core/toc-context.tsx @@ -0,0 +1,41 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback } from "react"; + +export type TocItem = { + id: string; + title: string; + depth?: number; +}; + +type TOCContextType = { + items: TocItem[]; + setItems: (items: TocItem[]) => void; + activeId: string | null; + setActiveId: (id: string | null) => void; +}; + +const TOCContext = createContext(undefined); + +export function TOCProvider({ children }: { children: React.ReactNode }) { + const [items, setItemsState] = useState([]); + const [activeId, setActiveId] = useState(null); + + const setItems = useCallback((newItems: TocItem[]) => { + setItemsState(newItems); + }, []); + + return ( + + {children} + + ); +} + +export function useTOC() { + const context = useContext(TOCContext); + if (!context) { + throw new Error("useTOC must be used within a TOCProvider"); + } + return context; +} diff --git a/apps/platform/components/mdx/component-preview.tsx b/apps/platform/components/mdx/component-preview.tsx new file mode 100644 index 0000000..d376f1f --- /dev/null +++ b/apps/platform/components/mdx/component-preview.tsx @@ -0,0 +1,120 @@ +"use client"; + +import React, { useState } from "react"; +import { cn } from "@/lib/utils"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ViewIcon, FileCodeCornerIcon } from "@hugeicons/core-free-icons"; +import { CodeBlock } from "../showcase/code-block"; +import { GlassContainer } from "../core/3d-container"; + +interface ComponentPreviewProps { + children?: React.ReactNode; + code?: string; + className?: string; + video?: string; + poster?: string; +} + +export function ComponentPreview({ + children, + code, + className, + video, + poster, +}: ComponentPreviewProps) { + const [activeTab, setActiveTab] = useState<"preview" | "code">("preview"); + + return ( + +
+ {/* Header with tabs and actions */} +
+
+ {/* Preview Tab */} + + + {/* Code Tab */} + {code && ( + + )} +
+
+ + {/* Content */} +
+ {/* Preview Panel */} + {activeTab === "preview" && ( +
+
+ +
+ Loading... +
+ } + > + {video ? ( +
+
+ ) : ( + children + )} +
+
+
+ )} + + {/* Code Panel */} + {activeTab === "code" && code && ( +
+ {code} +
+ )} +
+
+
+ ); +} + +// Export a simpler version for MDX usage +export function CompPreview(props: ComponentPreviewProps) { + return ; +} diff --git a/apps/platform/components/mdx/installation-cmd.tsx b/apps/platform/components/mdx/installation-cmd.tsx new file mode 100644 index 0000000..10d8bf8 --- /dev/null +++ b/apps/platform/components/mdx/installation-cmd.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { CopyButton } from "../animate-ui/components/buttons/copy"; +import { AnimatePresence, motion } from "motion/react"; +import { ScrollFadeEffect } from "../scroll-fade-effect/scroll-fade-effect"; +import { GlassContainer } from "../core/3d-container"; +type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; +const PM_LIST = ["npm", "pnpm", "yarn", "bun"] as PackageManager[]; + +type InstallTrackingContext = { + component_slug?: string; + component_name?: string; + category?: string; + source?: string; +}; + +export interface InstallationItem { + install: string[]; + slug: string; + name: string; + category: string; +} + +export const InstallationCmd = ({ + activePackageManager, + setActivePackageManager, + item, + trackingContext, +}: { + activePackageManager: PackageManager; + setActivePackageManager: (pm: PackageManager) => void; + item: InstallationItem; + trackingContext?: InstallTrackingContext; +}) => { + void trackingContext; + + const getInstallCommand = (pm: PackageManager, baseCommand: string) => { + // Check if it's a shadcn add command + if ( + baseCommand.startsWith("npx shadcn") || + baseCommand.includes("shadcn@latest add") + ) { + const parts = baseCommand.split(" "); + const componentName = parts[parts.length - 1]; + switch (pm) { + case "npm": + return `npx shadcn@latest add ${componentName}`; + case "yarn": + return `npx shadcn@latest add ${componentName}`; + case "pnpm": + return `pnpm dlx shadcn@latest add ${componentName}`; + case "bun": + return `bunx --bun shadcn@latest add ${componentName}`; + default: + return baseCommand; + } + } + // For npm install commands + if ( + baseCommand.startsWith("npm install") || + baseCommand.startsWith("npm i ") + ) { + const packages = baseCommand.replace(/^npm (install|i) /, ""); + switch (pm) { + case "npm": + return `npm install ${packages}`; + case "yarn": + return `yarn add ${packages}`; + case "pnpm": + return `pnpm add ${packages}`; + case "bun": + return `bun add ${packages}`; + default: + return baseCommand; + } + } + return baseCommand; + }; + + return ( +
+ {/* Package Manager Tabs */} +
+ +
+ {PM_LIST.map((pm) => { + const isActive = activePackageManager === pm; + + return ( + + ); + })} +
+
+
+ + {/* Install Commands */} +
+ {item.install.map((cmd: string, idx: number) => { + const command = getInstallCommand(activePackageManager, cmd); + + return ( +
+ +
+ + + + {command} + + + +
+
+ + +
+ ); + })} +
+
+ ); +}; diff --git a/apps/platform/components/mdx/manual-installation.tsx b/apps/platform/components/mdx/manual-installation.tsx new file mode 100644 index 0000000..57c3a73 --- /dev/null +++ b/apps/platform/components/mdx/manual-installation.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { AnimatePresence, motion } from "motion/react"; +import { CopyButton } from "../animate-ui/components/buttons/copy"; +import { ScrollFadeEffect } from "../scroll-fade-effect/scroll-fade-effect"; +import { GlassContainer } from "../core/3d-container"; + +type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; +const PM_LIST = ["npm", "pnpm", "yarn", "bun"] as PackageManager[]; + +type InstallTrackingContext = { + component_slug?: string; + component_name?: string; + category?: string; + source?: string; +}; + +export function ManualInstallationCmd({ + activePackageManager, + setActivePackageManager, + dependencies, + trackingContext, +}: { + activePackageManager: PackageManager; + setActivePackageManager: (pm: PackageManager) => void; + dependencies?: string[]; + trackingContext?: InstallTrackingContext; +}) { + if (!dependencies || dependencies.length === 0) return null; + void trackingContext; + + const getCommand = (pm: PackageManager) => { + const pkgs = dependencies.join(" "); + switch (pm) { + case "npm": + return `npm install ${pkgs}`; + case "yarn": + return `yarn add ${pkgs}`; + case "pnpm": + return `pnpm add ${pkgs}`; + case "bun": + return `bun add ${pkgs}`; + } + }; + + const command = getCommand(activePackageManager); + + return ( +
+ {/* PM Switcher */} +
+ +
+ {PM_LIST.map((pm) => { + const isActive = pm === activePackageManager; + + return ( + + ); + })} +
+
+
+ + {/* Command */} +
+ +
+ + + + {command} + + + +
+
+ + { + if (!command) return; + }} + className="absolute top-2 right-2" + /> +
+
+ ); +} diff --git a/apps/platform/components/nav-main.tsx b/apps/platform/components/nav-main.tsx new file mode 100644 index 0000000..8808e61 --- /dev/null +++ b/apps/platform/components/nav-main.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowRight01Icon } from "@hugeicons/core-free-icons"; + +export function NavMain({ + items, + label = "Platform", + expandAll = false, +}: { + items: { + title: string; + url: string; + icon: React.ReactNode; + isActive?: boolean; + items?: { + title: string; + url: string; + }[]; + }[]; + label?: string; + expandAll?: boolean; +}) { + return ( + + {label} + + {items.map((item) => ( + + + + + {item.icon} + {item.title} + + + {item.items?.length ? ( + <> + + + + Toggle + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + ) : null} + + + ))} + + + ); +} diff --git a/apps/platform/components/nav-projects.tsx b/apps/platform/components/nav-projects.tsx new file mode 100644 index 0000000..8d6db72 --- /dev/null +++ b/apps/platform/components/nav-projects.tsx @@ -0,0 +1,86 @@ +"use client" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" +import { HugeiconsIcon } from "@hugeicons/react" +import { MoreHorizontalCircle01Icon, FolderIcon, Share03Icon, Delete02Icon } from "@hugeicons/core-free-icons" + +export function NavProjects({ + projects, +}: { + projects: { + name: string + url: string + icon: React.ReactNode + }[] +}) { + const { isMobile } = useSidebar() + + return ( + + Projects + + {projects.map((item) => ( + + + + {item.icon} + {item.name} + + + + + + + More + + + + + + View Project + + + + Share Project + + + + + Delete Project + + + + + ))} + + + + More + + + + + ) +} diff --git a/apps/platform/components/nav-secondary.tsx b/apps/platform/components/nav-secondary.tsx new file mode 100644 index 0000000..3afbe30 --- /dev/null +++ b/apps/platform/components/nav-secondary.tsx @@ -0,0 +1,41 @@ +"use client" + +import * as React from "react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function NavSecondary({ + items, + ...props +}: { + items: { + title: string + url: string + icon: React.ReactNode + }[] +} & React.ComponentPropsWithoutRef) { + return ( + + + + {items.map((item) => ( + + + + {item.icon} + {item.title} + + + + ))} + + + + ) +} diff --git a/apps/platform/components/nav-user.tsx b/apps/platform/components/nav-user.tsx new file mode 100644 index 0000000..02ba547 --- /dev/null +++ b/apps/platform/components/nav-user.tsx @@ -0,0 +1,107 @@ +"use client" + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" +import { HugeiconsIcon } from "@hugeicons/react" +import { UnfoldMoreIcon, SparklesIcon, CheckmarkBadgeIcon, CreditCardIcon, NotificationIcon, LogoutIcon } from "@hugeicons/core-free-icons" + +export function NavUser({ + user, +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + + return ( + + + + + + + + CN + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Upgrade to Pro + + + + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ) +} diff --git a/apps/platform/components/scroll-fade-effect/scroll-fade-effect.tsx b/apps/platform/components/scroll-fade-effect/scroll-fade-effect.tsx new file mode 100644 index 0000000..ad4d16e --- /dev/null +++ b/apps/platform/components/scroll-fade-effect/scroll-fade-effect.tsx @@ -0,0 +1,29 @@ +import type { ComponentProps } from "react" + +import { cn } from "@/lib/utils" + +export type ScrollFadeEffectProps = ComponentProps<"div"> & { + /** + * Scroll direction to apply the fade effect. + * @defaultValue "vertical" + * */ + orientation?: "horizontal" | "vertical" +} + +export function ScrollFadeEffect({ + className, + orientation = "vertical", + ...props +}: ScrollFadeEffectProps) { + return ( +
+ ) +} diff --git a/apps/platform/components/showcase/code-block.tsx b/apps/platform/components/showcase/code-block.tsx index 636b5e7..db56025 100644 --- a/apps/platform/components/showcase/code-block.tsx +++ b/apps/platform/components/showcase/code-block.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { useTheme } from "next-themes"; import { cn } from "@/lib/utils"; import { CopyButton } from "../animate-ui/components/buttons/copy"; +import { GlassContainer } from "../core/3d-container"; type HighlighterProps = Record & { children?: string; @@ -140,53 +141,61 @@ export function CodeBlock({ }, []); return ( -
-
-

- {title || language} -

- - +
+
+

+ {title || language} +

+ + +
+ + {syntax ? ( + + {code} + + ) : ( +
+            
+              {code}
+            
+          
+ )}
- - {syntax ? ( - - {code} - - ) : ( -
-          {code}
-        
- )} -
+ ); } diff --git a/apps/platform/components/showcase/component-docs.tsx b/apps/platform/components/showcase/component-docs.tsx index f4b96f2..6c685aa 100644 --- a/apps/platform/components/showcase/component-docs.tsx +++ b/apps/platform/components/showcase/component-docs.tsx @@ -9,14 +9,20 @@ export async function ComponentsIndexView() { return (
- +
+ +
{categories.map((category) => ( -
+

{category.title} diff --git a/apps/platform/components/showcase/component-installation.tsx b/apps/platform/components/showcase/component-installation.tsx new file mode 100644 index 0000000..b990a62 --- /dev/null +++ b/apps/platform/components/showcase/component-installation.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { + InstallationCmd, + type InstallationItem, +} from "@/components/mdx/installation-cmd"; +import { ManualInstallationCmd } from "@/components/mdx/manual-installation"; +import { CodeBlock } from "@/components/showcase/code-block"; +import { DocsStep, DocsSteps } from "@/components/showcase/docs-steps"; + +type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; + +interface ComponentInstallationProps { + item: InstallationItem; + dependencies?: string[]; +} + +export function ComponentInstallation({ + item, + dependencies, +}: ComponentInstallationProps) { + const [activePackageManager, setActivePackageManager] = + useState("npm"); + const importSnippet = `import { ${item.name} } from "@/components/ui/${item.slug}";`; + const steps = [ + { + title: "Install the component", + description: ( +

+ Run the registry command below to add {item.slug} to + your project. +

+ ), + content: ( + + ), + }, + ...(dependencies?.length + ? [ + { + title: "Install manual dependencies", + description: ( +

+ If you are wiring the component manually, install the package + dependencies shown below. +

+ ), + content: ( + + ), + }, + ] + : []), + { + title: "Import the component", + description: ( +

+ Import {item.name} from your local UI registry output. +

+ ), + content: ( + + {importSnippet} + + ), + }, + ]; + + return ( +
+
+

Installation

+

+ Install the registry item directly, then add any package dependencies + if you are setting the component up manually. +

+
+ + + {steps.map((step, index) => ( + + {step.content} + + ))} + +
+ ); +} diff --git a/apps/platform/components/showcase/docs-primitives.tsx b/apps/platform/components/showcase/docs-primitives.tsx index 0fdb3f1..74f161c 100644 --- a/apps/platform/components/showcase/docs-primitives.tsx +++ b/apps/platform/components/showcase/docs-primitives.tsx @@ -1,10 +1,16 @@ +"use client"; + import Link from "next/link"; import { QRCodeSVG } from "qrcode.react"; import { cn } from "@/lib/utils"; +import { useEffect } from "react"; +import { useTOC } from "@/components/core/toc-context"; +import { GlassContainer } from "@/components/core/3d-container"; export type TocItem = { id: string; title: string; + depth?: number; }; export function DocSection({ @@ -19,7 +25,7 @@ export function DocSection({ className?: string; }) { return ( -
+

{title}

{children}
@@ -38,7 +44,7 @@ export function DocSubsection({ className?: string; }) { return ( -
+

{title}

{children}
@@ -56,60 +62,52 @@ export function ApiTable({ }>; }) { return ( -
- - - - - - - - - - - {rows.map((row) => ( - - - - - + +
+
proptypedefaultdescription
- - {row.prop} - - - {row.type} - - {row.default || "-"} - - {row.description} -
+ + + + + + - ))} - -
proptypedefaultdescription
-
+ + + {rows.map((row) => ( + + + + {row.prop} + + + + {row.type} + + + {row.default || "-"} + + + {row.description} + + + ))} + + +
+ ); } export function OnThisPage({ items }: { items: TocItem[] }) { - return ( -
-

- On this page -

- -
- ); + const { setItems } = useTOC(); + + useEffect(() => { + setItems(items); + return () => setItems([]); + }, [items, setItems]); + + return null; } export function PreviewCard({ @@ -174,24 +172,28 @@ export function DocsPager({ return (
{previous ? ( - -

Previous

-

{previous.title}

- + + +

Previous

+

{previous.title}

+ +
) : (
)} {next ? ( - -

Next

-

{next.title}

- + + +

Next

+

{next.title}

+ +
) : null}
); diff --git a/apps/platform/components/showcase/docs-steps.tsx b/apps/platform/components/showcase/docs-steps.tsx new file mode 100644 index 0000000..d3a13a0 --- /dev/null +++ b/apps/platform/components/showcase/docs-steps.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { GlassContainer } from "../core/3d-container"; + +export function DocsSteps({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} + +export function DocsStep({ + index, + title, + description, + children, + isLast = false, + className, +}: { + index: number; + title: string; + description?: React.ReactNode; + children: React.ReactNode; + isLast?: boolean; + className?: string; +}) { + return ( +
+
+ +
+ {index} +
+
+ {!isLast ? ( +
+ ) : null} +
+ +
+
+

+ {title} +

+ {description ? ( +
+ {description} +
+ ) : null} +
+
{children}
+
+
+ ); +} diff --git a/apps/platform/components/showcase/video-card.tsx b/apps/platform/components/showcase/video-card.tsx index d9fae44..a8439cc 100644 --- a/apps/platform/components/showcase/video-card.tsx +++ b/apps/platform/components/showcase/video-card.tsx @@ -35,7 +35,7 @@ export function CardCard({ item, onClick, trackType = "Card" }: CardProps) { onClick(item); }} className={cn( - "group relative flex flex-col", + "group relative flex min-w-0 max-w-full flex-col", "border-border/70 bg-card/80 rounded-lg border p-1 shadow-sm", "transition-all duration-200", item.comingSoon diff --git a/apps/platform/components/ui/avatar.tsx b/apps/platform/components/ui/avatar.tsx new file mode 100644 index 0000000..47ff2e2 --- /dev/null +++ b/apps/platform/components/ui/avatar.tsx @@ -0,0 +1,112 @@ +"use client" + +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/apps/platform/components/ui/breadcrumb.tsx b/apps/platform/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..1f4b75a --- /dev/null +++ b/apps/platform/components/ui/breadcrumb.tsx @@ -0,0 +1,122 @@ +import * as React from "react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" +import { HugeiconsIcon } from "@hugeicons/react" +import { ArrowRight01Icon, MoreHorizontalCircle01Icon } from "@hugeicons/core-free-icons" + +function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { + return ( +