diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 67420b32d5..2bc814dd0f 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -8,13 +8,15 @@ import * as v from "valibot"; import { detectPM } from "../utils/auto-detect.js"; import { ConfigError, error, handleError } from "../utils/errors.js"; import * as cliConfig from "../utils/get-config.js"; -import { getEnvProxy, getEnvRegistry } from "../utils/get-env-proxy.js"; +import { getEnvProxy } from "../utils/get-env-proxy.js"; import { cancel, intro, prettifyList } from "../utils/prompt-helpers.js"; import * as p from "../utils/prompts.js"; import * as registry from "../utils/registry/index.js"; import { transformContent } from "../utils/transformers.js"; import { resolveCommand } from "package-manager-detector/commands"; import { checkPreconditions } from "../utils/preconditions.js"; +import { isUrl, urlSplitLastPathSegment } from "../utils/utils.js"; +import { RegistryWithContent } from "../utils/registry/schema.js"; const highlight = (...args: unknown[]) => color.bold.cyan(...args); @@ -34,7 +36,7 @@ type AddOptions = v.InferOutput; export const add = new Command() .command("add") .description("add components to your project") - .argument("[components...]", "name of components") + .argument("[components...]", "the components to add or a url to the component") .option("-c, --cwd ", "the working directory", process.cwd()) .option("--no-deps", "skips adding & installing package dependencies") .option("-a, --all", "install all components to your project", false) @@ -63,10 +65,6 @@ export const add = new Command() ); } - const registryEnv = getEnvRegistry(); - - registry.setRegistry(registryEnv ? registryEnv : config.registry); - checkPreconditions(cwd); await runAdd(cwd, config, options); @@ -83,22 +81,39 @@ async function runAdd(cwd: string, config: cliConfig.Config, options: AddOptions p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); } - const uiRegistryIndex = await registry.getRegistryIndex(); + const registryUrl = registry.getRegistryUrl(config); - let selectedComponents = new Set( - options.all ? uiRegistryIndex.map(({ name }) => name) : options.components - ); + // get the base urls for any of the remote registries + const remoteRegistries = + options.components + ?.filter((c) => isUrl(c)) + .map((c) => urlSplitLastPathSegment(new URL(c))[0]) ?? []; + + const onlyRemoteComponents = + options.components?.length && remoteRegistries.length === options.components.length; - const registryDepMap = new Map(); - for (const item of uiRegistryIndex) { - registryDepMap.set(item.name, item.registryDependencies); + // if the components aren't just remote components or we want to include all components + // then we add then shadcn-svelte registry + if (!onlyRemoteComponents || options.all) { + remoteRegistries.push(registryUrl); } - if (selectedComponents === undefined || selectedComponents.size === 0) { + // maps the registry baseUrl to the index of the registry + const registryIndexes = await registry.getRegistryIndexes(remoteRegistries); + + // The index of the shadcn-svelte registry + const ogIndex = registryIndexes.get(registryUrl); + + let selectedComponents = new Set( + options.all ? ogIndex?.map(({ name }) => name) : options.components + ); + + // if the user hasn't passed any components prompt them to select components + if (ogIndex && selectedComponents.size === 0) { const components = await p.multiselect({ message: `Which ${highlight("components")} would you like to install?`, maxItems: 10, - options: uiRegistryIndex.map(({ name, dependencies, registryDependencies }) => { + options: ogIndex.map(({ name, dependencies, registryDependencies }) => { const deps = [...(options.deps ? dependencies : []), ...registryDependencies]; return { label: name, @@ -115,55 +130,86 @@ async function runAdd(cwd: string, config: cliConfig.Config, options: AddOptions p.log.step(`Components to install:\n${color.gray(prettyList)}`); } - /** - * Adds all the selected items and their registry dependencies to the `selectedComponents` - * set so that they can be individually overwritten. - */ + // load registry dependencies + const registryDepMap = new Map>(); + for (const [url, index] of registryIndexes) { + const registryDeps = new Map(); + + for (const item of index) { + registryDeps.set(item.name, item.registryDependencies); + } + + registryDepMap.set(url, registryDeps); + } + + const registryComponents = new Map>(); + + // theoretically this should all run without fetch calls if the index includes all the files + // in other words no need to parallelize for (const name of selectedComponents) { - if (registryDepMap.has(name)) { - /** - * We will have all the `ui` registry dependencies in the `registryDepMap`, - * so if the `name` is a `ui` component, we go ahead and add its dependencies - * to the `selectedComponents` set. - */ - const regDeps: string[] = registryDepMap.get(name) ?? []; - for (const dep of regDeps) { - selectedComponents.add(dep); - } - } else { - /** - * For blocks, hooks, etc. we need to resolve the tree to get their dependencies - * and add them to the `selectedComponents` set. - */ - const tree = await registry.resolveTree({ - index: uiRegistryIndex, - names: [name], - includeRegDeps: true, - config, - }); - for (const item of tree) { - for (const dep of item.registryDependencies) { - // we first add the reg dep to the selected components - selectedComponents.add(dep); - const depRegDeps: string[] = registryDepMap.get(dep) ?? []; - // we then add each of that dep's deps to the `selectedComponents` set - for (const depRegDep of depRegDeps) { - selectedComponents.add(depRegDep); - } + let componentName = name; + let componentRegistry = registryUrl; + + // handle remote components + if (isUrl(name)) { + // name should come in like `https://example.com/r/avatar.json` + // we split it to get the base url and avatar.json + // eslint-disable-next-line prefer-const -- wrong + let [baseUrl, item] = urlSplitLastPathSegment(new URL(name)); + + componentRegistry = baseUrl; + componentName = item.replace(".json", ""); + } + + // we already defined this so we know it exists + const index = registryIndexes.get(componentRegistry)!; + + const tree = await registry.resolveTree({ + baseUrl: componentRegistry, + names: [componentName], + config, + index, + includeRegDeps: true, + }); + + const installedComponents = registryComponents.get(componentRegistry) ?? new Set(); + + // add the current component + installedComponents.add(componentName); + + for (const item of tree) { + for (const dep of item.registryDependencies) { + // we first add the reg dep to the selected components + installedComponents.add(dep); + const depRegDeps: string[] = registryDepMap.get(componentRegistry)!.get(dep) ?? []; + // we then add each of that dep's deps to the `selectedComponents` set + for (const depRegDep of depRegDeps) { + installedComponents.add(depRegDep); } } } + + registryComponents.set(componentRegistry, installedComponents); } - const tree = await registry.resolveTree({ - index: uiRegistryIndex, - names: Array.from(selectedComponents), - includeRegDeps: false, - config, - }); + const fetchContent = async (url: string): Promise => { + const index = registryIndexes.get(url)!; + const components = registryComponents.get(url)!; - const payload = await registry.fetchTree(tree); - // const baseColor = await getRegistryBaseColor(config.tailwind.baseColor); + const tree = await registry.resolveTree({ + baseUrl: url, + index: index, + names: Array.from(components), + includeRegDeps: false, + config, + }); + + return await registry.fetchTree(url, tree); + }; + + const payload = (await Promise.all(remoteRegistries.map((url) => fetchContent(url)))).flatMap( + (p) => p + ); if (payload.length === 0) cancel("Selected components not found."); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 5a050ed8a2..50878f09a0 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -23,7 +23,6 @@ import * as templates from "../utils/templates.js"; import { resolveCommand } from "package-manager-detector/commands"; import { SITE_BASE_URL } from "../constants.js"; import { checkPreconditions } from "../utils/preconditions.js"; -import { getEnvRegistry } from "../utils/get-env-proxy.js"; const PROJECT_DEPENDENCIES = [ "tailwind-variants", @@ -81,10 +80,6 @@ export const init = new Command() const existingConfig = await cliConfig.getConfig(cwd); const config = await promptForConfig(cwd, existingConfig, options); - const registryEnv = getEnvRegistry(); - - registry.setRegistry(registryEnv ? registryEnv : config.registry); - await runInit(cwd, config, options); p.outro(`${color.green("Success!")} Project initialization completed.`); @@ -267,6 +262,8 @@ function validateImportAlias(alias: string, langConfig: DetectLanguageResult) { } export async function runInit(cwd: string, config: Config, options: InitOptions) { + const registryUrl = registry.getRegistryUrl(config); + const tasks: p.Task[] = []; // Write to file. @@ -303,7 +300,10 @@ export async function runInit(cwd: string, config: Config, options: InitOptions) } // Write css file. - const baseColor = await registry.getRegistryBaseColor(config.tailwind.baseColor); + const baseColor = await registry.getRegistryBaseColor( + registryUrl, + config.tailwind.baseColor + ); if (baseColor) { await fs.writeFile( config.resolvedPaths.tailwindCss, diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 704c6c08de..821f3dd566 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -8,7 +8,7 @@ import * as v from "valibot"; import { detectPM } from "../utils/auto-detect.js"; import { error, handleError } from "../utils/errors.js"; import * as cliConfig from "../utils/get-config.js"; -import { getEnvProxy, getEnvRegistry } from "../utils/get-env-proxy.js"; +import { getEnvProxy } from "../utils/get-env-proxy.js"; import { cancel, intro, prettifyList } from "../utils/prompt-helpers.js"; import * as p from "../utils/prompts.js"; import * as registry from "../utils/registry/index.js"; @@ -59,10 +59,6 @@ export const update = new Command() ); } - const registryEnv = getEnvRegistry(); - - registry.setRegistry(registryEnv ? registryEnv : config.registry); - checkPreconditions(cwd); await runUpdate(cwd, config, options); @@ -83,8 +79,11 @@ async function runUpdate(cwd: string, config: cliConfig.Config, options: UpdateO p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); } + const registryUrl = registry.getRegistryUrl(config); + const components = options.components; - const registryIndex = await registry.getRegistryIndex(); + + const registryIndex = await registry.getRegistryIndex(registryUrl); const componentDir = path.resolve(config.resolvedPaths.components, "ui"); if (!existsSync(componentDir)) { @@ -179,11 +178,14 @@ async function runUpdate(cwd: string, config: cliConfig.Config, options: UpdateO } const tree = await registry.resolveTree({ + baseUrl: registryUrl, index: registryIndex, names: selectedComponents.map((com) => com.name), config, }); - const payload = (await registry.fetchTree(tree)).sort((a, b) => a.name.localeCompare(b.name)); + const payload = (await registry.fetchTree(registryUrl, tree)).sort((a, b) => + a.name.localeCompare(b.name) + ); const componentsToRemove: Record = {}; const dependencies = new Set(); diff --git a/packages/cli/src/utils/get-env-proxy.ts b/packages/cli/src/utils/get-env-proxy.ts index 973c183a1f..1a17685a1a 100644 --- a/packages/cli/src/utils/get-env-proxy.ts +++ b/packages/cli/src/utils/get-env-proxy.ts @@ -11,9 +11,3 @@ export function getEnvProxy(): string | undefined { env.npm_config_https_proxy ); } - -export function getEnvRegistry(): string | undefined { - const { env } = process; - - return env.COMPONENTS_REGISTRY_URL; -} diff --git a/packages/cli/src/utils/registry/index.ts b/packages/cli/src/utils/registry/index.ts index 0199992533..e6cf34bcfd 100644 --- a/packages/cli/src/utils/registry/index.ts +++ b/packages/cli/src/utils/registry/index.ts @@ -7,33 +7,42 @@ import type { Config } from "../get-config.js"; import { getEnvProxy } from "../get-env-proxy.js"; import * as schemas from "./schema.js"; -let baseUrl: string | undefined; +export function getRegistryUrl(config: Config) { + let url = process.env.COMPONENTS_REGISTRY_URL; -export type RegistryItem = v.InferOutput; + if (url) return url; + + url = config.registry; -export function setRegistry(url: string) { // temp workaround to circumvent some caching issues with CF between subdomain / root domain // this will be removed once we have a proper solution and or we merge with main if (url === "https://next.shadcn-svelte.com/registry") { - baseUrl = "https://huntabyte-next.shadcn-svelte.pages.dev/registry"; - } else { - baseUrl = url; + return "https://huntabyte-next.shadcn-svelte.pages.dev/registry"; } + + return url; } -export function getRegistryUrl(path: string) { - if (!baseUrl) throw new Error("Registry URL not set"); +export type RegistryItem = v.InferOutput; - if (isUrl(path)) { - const url = new URL(path); - return url.toString(); - } - return `${baseUrl}/${path}`; +/** Concurrently loads all of the registry indexes */ +export async function getRegistryIndexes(registryUrls: string[]) { + const result = new Map(); + + const loadRegistry = async (registryUrl: string) => { + const index = await getRegistryIndex(registryUrl); + + result.set(registryUrl, index); + }; + + await Promise.all(registryUrls.map((url) => loadRegistry(url))); + + return result; } -export async function getRegistryIndex() { +export async function getRegistryIndex(baseUrl: string) { try { - const [result] = await fetchRegistry(["index.json"]); + const [result] = await fetchRegistry(baseUrl, ["index.json"]); return v.parse(schemas.registryIndexSchema, result); } catch (e) { @@ -52,9 +61,9 @@ export function getBaseColors() { ]; } -export async function getRegistryBaseColor(baseColor: string) { +export async function getRegistryBaseColor(baseUrl: string, baseColor: string) { try { - const [result] = await fetchRegistry([`colors/${baseColor}.json`]); + const [result] = await fetchRegistry(baseUrl, [`colors/${baseColor}.json`]); return v.parse(schemas.registryBaseColorSchema, result); } catch (err) { @@ -65,6 +74,7 @@ export async function getRegistryBaseColor(baseColor: string) { type RegistryIndex = v.InferOutput; type ResolveTreeProps = { + baseUrl: string; index: RegistryIndex; names: string[]; includeRegDeps?: boolean; @@ -73,6 +83,7 @@ type ResolveTreeProps = { export async function resolveTree({ index, + baseUrl, names, includeRegDeps = true, config, @@ -83,7 +94,7 @@ export async function resolveTree({ let entry = index.find((entry) => entry.name === name); if (!entry) { - const [item] = await fetchRegistry([`${name}.json`]); + const [item] = await fetchRegistry(baseUrl, [`${name}.json`]); if (item) entry = item; if (!entry) continue; } @@ -92,6 +103,7 @@ export async function resolveTree({ if (includeRegDeps && entry.registryDependencies) { const dependencies = await resolveTree({ + baseUrl, index, names: entry.registryDependencies, config, @@ -105,10 +117,10 @@ export async function resolveTree({ ); } -export async function fetchTree(tree: RegistryIndex) { +export async function fetchTree(baseUrl: string, tree: RegistryIndex) { try { const paths = tree.map((item) => `${item.name}.json`); - const result = await fetchRegistry(paths); + const result = await fetchRegistry(baseUrl, paths); return v.parse(schemas.registryWithContentSchema, result); } catch (e) { @@ -133,15 +145,13 @@ export function getItemTargetPath( return path.join(config.resolvedPaths[type as keyof typeof config.resolvedPaths]); } -async function fetchRegistry(paths: string[]) { - if (!baseUrl) throw new Error("Registry URL not set"); - +async function fetchRegistry(baseUrl: string, paths: string[]) { const proxyUrl = getEnvProxy(); const proxy = proxyUrl ? createProxy({ url: proxyUrl }) : {}; try { const results = await Promise.all( paths.map(async (path) => { - const url = getRegistryUrl(path); + const url = `${baseUrl}/${path}`; const response = await fetch(url, { ...proxy, @@ -168,15 +178,6 @@ async function fetchRegistry(paths: string[]) { } } -function isUrl(path: string) { - try { - new URL(path); - return true; - } catch { - return false; - } -} - export function getRegistryItemTargetPath( config: Config, type: schemas.RegistryItemType, diff --git a/packages/cli/src/utils/registry/schema.ts b/packages/cli/src/utils/registry/schema.ts index 055f3fee94..31915b118a 100644 --- a/packages/cli/src/utils/registry/schema.ts +++ b/packages/cli/src/utils/registry/schema.ts @@ -57,6 +57,8 @@ export const registryItemWithContentSchema = v.object({ }).entries, }); +export type RegistryIndex = v.InferOutput; + export const registryWithContentSchema = v.array(registryItemWithContentSchema); export const registryBaseColorSchema = v.object({ @@ -71,3 +73,5 @@ export const registryBaseColorSchema = v.object({ inlineColorsTemplate: v.string(), cssVarsTemplate: v.string(), }); + +export type RegistryWithContent = v.InferOutput; diff --git a/packages/cli/src/utils/utils.ts b/packages/cli/src/utils/utils.ts new file mode 100644 index 0000000000..efd95bf62a --- /dev/null +++ b/packages/cli/src/utils/utils.ts @@ -0,0 +1,17 @@ +export function isUrl(path: string) { + try { + new URL(path); + return true; + } catch { + return false; + } +} + +/** Returns the base url and the last segment of the pathname of the given url. Expects a valid URL. */ +export function urlSplitLastPathSegment(url: URL): [string, string] { + const lastIndex = url.toString().lastIndexOf("/"); + + const urlString = url.toString(); + + return [urlString.slice(0, lastIndex), urlString.slice(lastIndex + 1)]; +} diff --git a/packages/cli/test/utils/utils.spec.ts b/packages/cli/test/utils/utils.spec.ts new file mode 100644 index 0000000000..495ae164eb --- /dev/null +++ b/packages/cli/test/utils/utils.spec.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; +import { urlSplitLastPathSegment } from "../../src/utils/utils"; + +describe("urlSplitLastPathSegment", () => { + it("Correctly returns the expected segments", () => { + expect( + urlSplitLastPathSegment(new URL("https://example.com/registry/index.json")) + ).toStrictEqual(["https://example.com/registry", "index.json"]); + }); +});